// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "../../src/abstracts/ThreePositionStrategy.sol"; import "../helpers/TestBase.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import "forge-std/Test.sol"; /** * @title ThreePositionStrategy Test Suite * @notice Unit tests for the anti-arbitrage three-position strategy (Floor, Anchor, Discovery) */ // Mock implementation for testing ThreePositionStrategy contract MockThreePositionStrategy is ThreePositionStrategy { address public harbToken; address public wethToken; bool public token0IsWeth; uint256 public ethBalance; uint256 public outstandingSupply; // Track minted positions for testing struct MintedPosition { Stage stage; int24 tickLower; int24 tickUpper; uint128 liquidity; } MintedPosition[] public mintedPositions; constructor(address _harbToken, address _wethToken, bool _token0IsWeth, uint256 _ethBalance, uint256 _outstandingSupply) { harbToken = _harbToken; wethToken = _wethToken; token0IsWeth = _token0IsWeth; ethBalance = _ethBalance; outstandingSupply = _outstandingSupply; } // Test helper functions function setEthBalance(uint256 _ethBalance) external { ethBalance = _ethBalance; } function setOutstandingSupply(uint256 _outstandingSupply) external { outstandingSupply = _outstandingSupply; } function setVWAP(uint256 vwapX96, uint256 volume) external { // Mock VWAP data for testing cumulativeVolumeWeightedPriceX96 = vwapX96 * volume; cumulativeVolume = volume; } function clearMintedPositions() external { delete mintedPositions; } function getMintedPositionsCount() external view returns (uint256) { return mintedPositions.length; } function getMintedPosition(uint256 index) external view returns (MintedPosition memory) { return mintedPositions[index]; } // Expose internal functions for testing function setPositions(int24 currentTick, PositionParams memory params) external { _setPositions(currentTick, params); } function setAnchorPosition(int24 currentTick, uint256 anchorEthBalance, PositionParams memory params) external returns (uint256, uint128) { return _setAnchorPosition(currentTick, anchorEthBalance, params); } function setDiscoveryPosition(int24 currentTick, uint128 anchorLiquidity, PositionParams memory params) external returns (uint256) { return _setDiscoveryPosition(currentTick, anchorLiquidity, params); } function setFloorPosition(int24 currentTick, uint256 floorEthBalance, uint256 pulledHarb, uint256 discoveryAmount, PositionParams memory params) external { _setFloorPosition(currentTick, floorEthBalance, pulledHarb, discoveryAmount, params); } // Implementation of abstract functions function _getKraikenToken() internal view override returns (address) { return harbToken; } function _getWethToken() internal view override returns (address) { return wethToken; } function _isToken0Weth() internal view override returns (bool) { return token0IsWeth; } function _mintPosition(Stage stage, int24 tickLower, int24 tickUpper, uint128 liquidity) internal override { positions[stage] = TokenPosition({ liquidity: liquidity, tickLower: tickLower, tickUpper: tickUpper }); mintedPositions.push(MintedPosition({ stage: stage, tickLower: tickLower, tickUpper: tickUpper, liquidity: liquidity })); } function _getEthBalance() internal view override returns (uint256) { return ethBalance; } function _getOutstandingSupply() internal view override returns (uint256) { return outstandingSupply; } } contract ThreePositionStrategyTest is TestConstants { MockThreePositionStrategy internal strategy; address internal constant HARB_TOKEN = address(0x1234); address internal constant WETH_TOKEN = address(0x5678); // Default test parameters int24 internal constant CURRENT_TICK = 0; uint256 internal constant ETH_BALANCE = 100 ether; uint256 internal constant OUTSTANDING_SUPPLY = 1_000_000 ether; function setUp() public { strategy = new MockThreePositionStrategy( HARB_TOKEN, WETH_TOKEN, true, // token0IsWeth ETH_BALANCE, OUTSTANDING_SUPPLY ); } // Using getDefaultParams() from TestBase // ======================================== // ANCHOR POSITION TESTS // ======================================== function testAnchorPositionBasic() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); uint256 anchorEthBalance = 20 ether; // 20% of total (uint256 pulledHarb,) = strategy.setAnchorPosition(CURRENT_TICK, anchorEthBalance, params); // Verify position was created assertEq(strategy.getMintedPositionsCount(), 1, "Should have minted one position"); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); assertEq(uint256(pos.stage), uint256(ThreePositionStrategy.Stage.ANCHOR), "Should be anchor position"); assertGt(pos.liquidity, 0, "Liquidity should be positive"); assertGt(pulledHarb, 0, "Should pull some HARB tokens"); // Verify tick range is reasonable int24 expectedSpacing = 200 + (34 * 50 * 200 / 100); // TICK_SPACING + anchorWidth calculation assertEq(pos.tickUpper - pos.tickLower, expectedSpacing * 2, "Tick range should match anchor spacing"); } function testAnchorPositionSymmetricAroundCurrentTick() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); uint256 anchorEthBalance = 20 ether; strategy.setAnchorPosition(CURRENT_TICK, anchorEthBalance, params); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); // Position should be symmetric around current tick int24 centerTick = (pos.tickLower + pos.tickUpper) / 2; int24 normalizedCurrentTick = CURRENT_TICK / 200 * 200; // Normalize to tick spacing assertApproxEqAbs(uint256(int256(centerTick)), uint256(int256(normalizedCurrentTick)), 200, "Anchor should be centered around current tick"); } function testAnchorPositionWidthScaling() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); params.anchorWidth = 100; // Maximum width uint256 anchorEthBalance = 20 ether; strategy.setAnchorPosition(CURRENT_TICK, anchorEthBalance, params); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); // Calculate expected spacing for 100% width int24 expectedSpacing = 200 + (34 * 100 * 200 / 100); // Should be 7000 assertEq(pos.tickUpper - pos.tickLower, expectedSpacing * 2, "Width should scale with anchorWidth parameter"); } // ======================================== // DISCOVERY POSITION TESTS // ======================================== function testDiscoveryPositionDependsOnAnchor() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); uint128 anchorLiquidity = 1000e18; // Simulated anchor liquidity uint256 discoveryAmount = strategy.setDiscoveryPosition(CURRENT_TICK, anchorLiquidity, params); // Discovery amount should be proportional to anchor liquidity assertGt(discoveryAmount, 0, "Discovery amount should be positive"); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); assertEq(uint256(pos.stage), uint256(ThreePositionStrategy.Stage.DISCOVERY), "Should be discovery position"); // Discovery liquidity should ensure multiple times more liquidity per tick uint256 expectedMultiplier = 200 + (800 * params.discoveryDepth / 10 ** 18); // Calculate anchor width (same calculation as in _setDiscoveryPosition) int24 anchorSpacing = 200 + (34 * int24(params.anchorWidth) * 200 / 100); int24 anchorWidth = 2 * anchorSpacing; // Adjust for width difference uint128 expectedLiquidity = uint128(uint256(anchorLiquidity) * expectedMultiplier * 11_000 / (100 * uint256(int256(anchorWidth)))); assertEq(pos.liquidity, expectedLiquidity, "Discovery liquidity should match expected multiple adjusted for width"); } function testDiscoveryPositionPlacement() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); bool token0IsWeth = true; // Test with WETH as token0 strategy = new MockThreePositionStrategy(HARB_TOKEN, WETH_TOKEN, token0IsWeth, ETH_BALANCE, OUTSTANDING_SUPPLY); uint128 anchorLiquidity = 1000e18; strategy.setDiscoveryPosition(CURRENT_TICK, anchorLiquidity, params); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); // When WETH is token0, discovery should be positioned below current price // (covering the range where HARB gets cheaper) assertLt(pos.tickUpper, CURRENT_TICK, "Discovery should be below current tick when WETH is token0"); } function testDiscoveryDepthScaling() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); params.discoveryDepth = 10 ** 18; // Maximum depth (100%) uint128 anchorLiquidity = 1000e18; uint256 discoveryAmount1 = strategy.setDiscoveryPosition(CURRENT_TICK, anchorLiquidity, params); strategy.clearMintedPositions(); params.discoveryDepth = 0; // Minimum depth uint256 discoveryAmount2 = strategy.setDiscoveryPosition(CURRENT_TICK, anchorLiquidity, params); assertGt(discoveryAmount1, discoveryAmount2, "Higher discovery depth should result in more tokens"); } // ======================================== // FLOOR POSITION TESTS // ======================================== function testFloorPositionUsesVWAP() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); // Set up VWAP data uint256 vwapX96 = 79_228_162_514_264_337_593_543_950_336; // 1.0 in X96 format strategy.setVWAP(vwapX96, 1000 ether); uint256 floorEthBalance = 80 ether; uint256 pulledHarb = 1000 ether; uint256 discoveryAmount = 500 ether; strategy.setFloorPosition(CURRENT_TICK, floorEthBalance, pulledHarb, discoveryAmount, params); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); assertEq(uint256(pos.stage), uint256(ThreePositionStrategy.Stage.FLOOR), "Should be floor position"); // Floor position should not be at current tick (should use VWAP) int24 centerTick = (pos.tickLower + pos.tickUpper) / 2; assertNotEq(centerTick, CURRENT_TICK, "Floor should not be positioned at current tick when VWAP available"); } function testFloorPositionScarcityDominates() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); // Set up scenario where ETH is insufficient → scarcity tick dominates uint256 vwapX96 = 79_228_162_514_264_337_593_543_950_336 * 10; // High VWAP price strategy.setVWAP(vwapX96, 1000 ether); uint256 smallEthBalance = 1 ether; // Very low ETH → scarcity tick far away uint256 pulledHarb = 1000 ether; uint256 discoveryAmount = 500 ether; // Should not revert — floor placed using max(scarcity, mirror, clamp) strategy.setFloorPosition(CURRENT_TICK, smallEthBalance, pulledHarb, discoveryAmount, params); // Floor should be minted (position exists) MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); assertTrue(pos.liquidity > 0, "Floor should have liquidity"); } function testFloorPositionMirrorDominates() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); // Set up scenario where VWAP is far from current → mirror tick dominates uint256 baseVwap = 79_228_162_514_264_337_593_543_950_336; // 1.0 in X96 format uint256 vwapX96 = baseVwap / 100_000; // Very low VWAP price → far from current strategy.setVWAP(vwapX96, 1000 ether); uint256 largeEthBalance = 100_000 ether; // Lots of ETH uint256 pulledHarb = 1000 ether; uint256 discoveryAmount = 500 ether; strategy.setFloorPosition(CURRENT_TICK, largeEthBalance, pulledHarb, discoveryAmount, params); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); assertTrue(pos.liquidity > 0, "Floor should have liquidity"); // Floor should be further than just anchorSpacing (mirror should push it) int24 anchorSpacing = 200 + (34 * 50 * 200 / 100); // 3600 int24 floorCenter = (pos.tickLower + pos.tickUpper) / 2; // Mirror should push floor significantly beyond clamp minimum assertTrue(floorCenter > CURRENT_TICK + anchorSpacing + 200, "Mirror should push floor beyond clamp minimum"); } function testFloorPositionNoVWAP() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); // No VWAP data (volume = 0) strategy.setVWAP(0, 0); uint256 floorEthBalance = 80 ether; uint256 pulledHarb = 1000 ether; uint256 discoveryAmount = 500 ether; strategy.setFloorPosition(CURRENT_TICK, floorEthBalance, pulledHarb, discoveryAmount, params); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); // Without VWAP, mirror = current tick, so floor uses max(scarcity, clamp) // With these balances, scarcity tick should dominate (low ETH relative to supply) int24 centerTick = (pos.tickLower + pos.tickUpper) / 2; // Floor should be above current tick (on KRK-cheap side) assertTrue(centerTick > CURRENT_TICK, "Floor should be on KRK-cheap side of current tick"); assertTrue(pos.liquidity > 0, "Floor should have liquidity"); } function testFloorPositionNoVWAPClampOrScarcity() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); // No VWAP data, large ETH balance, small supply strategy.setVWAP(0, 0); uint256 floorEthBalance = 100_000 ether; // Very large uint256 pulledHarb = 100 ether; // Small supply uint256 discoveryAmount = 50 ether; strategy.setFloorPosition(CURRENT_TICK, floorEthBalance, pulledHarb, discoveryAmount, params); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); // With no VWAP: mirror = current. Floor uses max(scarcity, clamp). // The scarcity formula with small supply and large ETH may still push floor // significantly beyond the clamp minimum. Just verify floor is on correct side. int24 centerTick = (pos.tickLower + pos.tickUpper) / 2; int24 minSpacing = 200 + (34 * 50 * 200 / 100); // 3600 assertTrue(centerTick >= CURRENT_TICK + minSpacing, "Floor should be positioned away from current tick to avoid anchor overlap"); } function testFloorPositionOutstandingSupplyCalculation() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); uint256 initialSupply = 1_000_000 ether; uint256 pulledHarb = 50_000 ether; uint256 discoveryAmount = 30_000 ether; strategy.setOutstandingSupply(initialSupply); uint256 floorEthBalance = 80 ether; strategy.setFloorPosition(CURRENT_TICK, floorEthBalance, pulledHarb, discoveryAmount, params); // The outstanding supply calculation should account for both pulled and discovery amounts // We can't directly observe this, but it affects the VWAP price calculation // This test ensures the function completes without reverting assertEq(strategy.getMintedPositionsCount(), 1, "Floor position should be created"); } // ======================================== // INTEGRATED POSITION SETTING TESTS // ======================================== function testSetPositionsOrder() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); strategy.setPositions(CURRENT_TICK, params); // Should have created all three positions assertEq(strategy.getMintedPositionsCount(), 3, "Should create three positions"); // Verify order: ANCHOR, DISCOVERY, FLOOR MockThreePositionStrategy.MintedPosition memory pos1 = strategy.getMintedPosition(0); MockThreePositionStrategy.MintedPosition memory pos2 = strategy.getMintedPosition(1); MockThreePositionStrategy.MintedPosition memory pos3 = strategy.getMintedPosition(2); assertEq(uint256(pos1.stage), uint256(ThreePositionStrategy.Stage.ANCHOR), "First should be anchor"); assertEq(uint256(pos2.stage), uint256(ThreePositionStrategy.Stage.DISCOVERY), "Second should be discovery"); assertEq(uint256(pos3.stage), uint256(ThreePositionStrategy.Stage.FLOOR), "Third should be floor"); } function testSetPositionsEthAllocation() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); params.anchorShare = 2 * 10 ** 17; // 20% uint256 totalEth = 100 ether; strategy.setEthBalance(totalEth); strategy.setPositions(CURRENT_TICK, params); // Floor should get majority of ETH (75-95% according to contract logic) // Anchor should get remainder // This is validated by the positions being created successfully assertEq(strategy.getMintedPositionsCount(), 3, "All positions should be created with proper ETH allocation"); } function testSetPositionsAsymmetricProfile() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); strategy.setPositions(CURRENT_TICK, params); MockThreePositionStrategy.MintedPosition memory anchor = strategy.getMintedPosition(0); MockThreePositionStrategy.MintedPosition memory discovery = strategy.getMintedPosition(1); MockThreePositionStrategy.MintedPosition memory floor = strategy.getMintedPosition(2); // Verify asymmetric slippage profile // Anchor should have smaller range (shallow liquidity, high slippage) int24 anchorRange = anchor.tickUpper - anchor.tickLower; int24 discoveryRange = discovery.tickUpper - discovery.tickLower; int24 floorRange = floor.tickUpper - floor.tickLower; // Discovery and floor should generally have wider ranges than anchor assertGt(discoveryRange, anchorRange / 2, "Discovery should have meaningful range"); assertGt(floorRange, 0, "Floor should have positive range"); // All positions should be positioned relative to current tick assertGt(anchor.liquidity, 0, "Anchor should have liquidity"); assertGt(discovery.liquidity, 0, "Discovery should have liquidity"); assertGt(floor.liquidity, 0, "Floor should have liquidity"); } // ======================================== // POSITION BOUNDARY TESTS // ======================================== function testPositionBoundaries() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); strategy.setPositions(CURRENT_TICK, params); MockThreePositionStrategy.MintedPosition memory anchor = strategy.getMintedPosition(0); MockThreePositionStrategy.MintedPosition memory discovery = strategy.getMintedPosition(1); MockThreePositionStrategy.MintedPosition memory floor = strategy.getMintedPosition(2); // Verify positions don't overlap inappropriately // This is important for the valley liquidity strategy // All ticks should be properly aligned to tick spacing assertEq(anchor.tickLower % 200, 0, "Anchor lower tick should be aligned"); assertEq(anchor.tickUpper % 200, 0, "Anchor upper tick should be aligned"); assertEq(discovery.tickLower % 200, 0, "Discovery lower tick should be aligned"); assertEq(discovery.tickUpper % 200, 0, "Discovery upper tick should be aligned"); assertEq(floor.tickLower % 200, 0, "Floor lower tick should be aligned"); assertEq(floor.tickUpper % 200, 0, "Floor upper tick should be aligned"); } // ======================================== // PARAMETER VALIDATION TESTS // ======================================== function testParameterBounding() public { // Test that large but realistic parameters are handled gracefully ThreePositionStrategy.PositionParams memory extremeParams = ThreePositionStrategy.PositionParams({ capitalInefficiency: 10 ** 18, // 100% (maximum reasonable value) anchorShare: 10 ** 18, // 100% (maximum reasonable value) anchorWidth: 1000, // Very wide anchor discoveryDepth: 10 ** 18 // 100% (maximum reasonable value) }); // Should not revert even with extreme parameters strategy.setPositions(CURRENT_TICK, extremeParams); assertEq(strategy.getMintedPositionsCount(), 3, "Should handle extreme parameters gracefully"); } // ======================================== // ANCHOR WIDTH BOUNDS TESTS (#817) // ======================================== function testAnchorWidthAtMaxBoundarySucceeds() public { // MAX_ANCHOR_WIDTH = 1233: 34 * 1233 * 200 = 8,384,400 fits within int24 max (8,388,607) ThreePositionStrategy.PositionParams memory params = getDefaultParams(); params.anchorWidth = 1233; strategy.setAnchorPosition(CURRENT_TICK, 20 ether, params); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); assertTrue(pos.tickLower < pos.tickUpper, "tickLower must be less than tickUpper"); assertTrue(pos.tickLower >= -887272, "tickLower must be >= MIN_TICK"); assertTrue(pos.tickUpper <= 887272, "tickUpper must be <= MAX_TICK"); assertGt(pos.liquidity, 0, "Anchor should have positive liquidity"); } function testAnchorWidthAboveMaxOverflowsAtStrategyLayer() public { // Calling the strategy directly with anchorWidth=1234 panics at the int24 multiplication // (34 * 1234 * 200 = 8,391,200 > int24 max 8,388,607). This demonstrates why // LiquidityManager clamps anchorWidth to MAX_ANCHOR_WIDTH before calling _setPositions. ThreePositionStrategy.PositionParams memory params = getDefaultParams(); params.anchorWidth = 1234; vm.expectRevert(); strategy.setAnchorPosition(CURRENT_TICK, 20 ether, params); } }