- Implement dynamic discovery depth based on anchor position share - Add configurable discovery_max_multiple (1.5-4x) for flexible adjustment - Update BullMarketOptimizer with new depth calculation logic - Fix scenario visualizer floor position visibility - Add comprehensive tests for discovery depth behavior The discovery position now dynamically adjusts its depth based on the anchor position's share of total liquidity, allowing for more effective price discovery while maintaining protection against manipulation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
475 lines
No EOL
21 KiB
Solidity
475 lines
No EOL
21 KiB
Solidity
// 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, 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 _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();
|
|
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 * 11000 / (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 = 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");
|
|
}
|
|
} |