// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import "../../src/abstracts/ThreePositionStrategy.sol"; import "../helpers/TestBase.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) { return _setAnchorPosition(currentTick, anchorEthBalance, params); } function setDiscoveryPosition(int24 currentTick, uint256 pulledHarb, PositionParams memory params) external returns (uint256) { return _setDiscoveryPosition(currentTick, pulledHarb, 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 _getHarbToken() 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 strategy; address constant HARB_TOKEN = address(0x1234); address constant WETH_TOKEN = address(0x5678); // Default test parameters int24 constant CURRENT_TICK = 0; uint256 constant ETH_BALANCE = 100 ether; uint256 constant OUTSTANDING_SUPPLY = 1000000 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(); uint256 pulledHarb = 1000 ether; // Simulated from anchor uint256 discoveryAmount = strategy.setDiscoveryPosition(CURRENT_TICK, pulledHarb, params); // Discovery amount should be proportional to pulledHarb assertGt(discoveryAmount, 0, "Discovery amount should be positive"); assertGt(discoveryAmount, pulledHarb / 100, "Discovery should be meaningful portion of pulled HARB"); MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0); assertEq(uint256(pos.stage), uint256(ThreePositionStrategy.Stage.DISCOVERY), "Should be discovery position"); } 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); uint256 pulledHarb = 1000 ether; strategy.setDiscoveryPosition(CURRENT_TICK, pulledHarb, 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%) uint256 pulledHarb = 1000 ether; uint256 discoveryAmount1 = strategy.setDiscoveryPosition(CURRENT_TICK, pulledHarb, params); strategy.clearMintedPositions(); params.discoveryDepth = 0; // Minimum depth uint256 discoveryAmount2 = strategy.setDiscoveryPosition(CURRENT_TICK, pulledHarb, 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 = 79228162514264337593543950336; // 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 testFloorPositionEthScarcity() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); // Set up scenario where ETH is insufficient for VWAP price uint256 vwapX96 = 79228162514264337593543950336 * 10; // High VWAP price strategy.setVWAP(vwapX96, 1000 ether); uint256 smallEthBalance = 1 ether; // Insufficient ETH uint256 pulledHarb = 1000 ether; uint256 discoveryAmount = 500 ether; // Should emit EthScarcity event (check event type, not exact values) vm.expectEmit(true, false, false, false); emit ThreePositionStrategy.EthScarcity(CURRENT_TICK, 0, 0, 0, 0); strategy.setFloorPosition(CURRENT_TICK, smallEthBalance, pulledHarb, discoveryAmount, params); } function testFloorPositionEthAbundance() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); // Set up scenario where ETH is sufficient for VWAP price uint256 baseVwap = 79228162514264337593543950336; // 1.0 in X96 format uint256 vwapX96 = baseVwap / 100000; // Very low VWAP price to ensure abundance strategy.setVWAP(vwapX96, 1000 ether); uint256 largeEthBalance = 100000 ether; // Very large ETH balance uint256 pulledHarb = 1000 ether; uint256 discoveryAmount = 500 ether; // Should emit EthAbundance event (check event type, not exact values) // The exact VWAP and vwapTick values are calculated, so we just check the event type vm.expectEmit(true, false, false, false); emit ThreePositionStrategy.EthAbundance(CURRENT_TICK, 0, 0, 0, 0); strategy.setFloorPosition(CURRENT_TICK, largeEthBalance, pulledHarb, discoveryAmount, params); } 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, should default to current tick but adjusted for anchor spacing int24 centerTick = (pos.tickLower + pos.tickUpper) / 2; // Expected spacing: TICK_SPACING + (34 * anchorWidth * TICK_SPACING / 100) = 200 + (34 * 50 * 200 / 100) = 3600 int24 expectedSpacing = 200 + (34 * 50 * 200 / 100); assertApproxEqAbs(uint256(int256(centerTick)), uint256(int256(CURRENT_TICK + expectedSpacing)), 200, "Floor should be positioned away from current tick to avoid anchor overlap"); } function testFloorPositionOutstandingSupplyCalculation() public { ThreePositionStrategy.PositionParams memory params = getDefaultParams(); uint256 initialSupply = 1000000 ether; uint256 pulledHarb = 50000 ether; uint256 discoveryAmount = 30000 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"); } }