Add Solidity linting with solhint, Foundry formatter, and pre-commit hooks (#51)

## Changes

### Configuration
- Added .solhint.json with recommended rules + custom config
  - 160 char line length (warn)
  - Double quotes enforcement (error)
  - Explicit visibility required (error)
  - Console statements allowed (scripts/tests need them)
  - Gas optimization warnings enabled
  - Ignores test/helpers/, lib/, out/, cache/, broadcast/

- Added foundry.toml [fmt] section
  - 160 char line length
  - 4-space tabs
  - Double quotes
  - Thousands separators for numbers
  - Sort imports enabled

- Added .lintstagedrc.json for pre-commit auto-fix
  - Runs solhint --fix on .sol files
  - Runs forge fmt on .sol files

- Added husky pre-commit hook via lint-staged

### NPM Scripts
- lint:sol - run solhint
- lint:sol:fix - auto-fix solhint issues
- format:sol - format with forge fmt
- format:sol:check - check formatting
- lint / lint:fix - combined commands

### Code Changes
- Added explicit visibility modifiers (internal) to constants in scripts and tests
- Fixed quote style in DeployLocal.sol
- All Solidity files formatted with forge fmt

## Verification
-  forge fmt --check passes
-  No solhint errors (warnings only)
-  forge build succeeds
-  forge test passes (107/107)

resolves #44

Co-authored-by: johba <johba@harb.eth>
Reviewed-on: https://codeberg.org/johba/harb/pulls/51
This commit is contained in:
johba 2025-10-04 15:17:09 +02:00
parent f8927b426e
commit d7c2184ccf
45 changed files with 2853 additions and 1225 deletions

View file

@ -1,10 +1,10 @@
// 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";
import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import "forge-std/Test.sol";
/**
* @title ThreePositionStrategy Test Suite
@ -18,7 +18,7 @@ contract MockThreePositionStrategy is ThreePositionStrategy {
bool public token0IsWeth;
uint256 public ethBalance;
uint256 public outstandingSupply;
// Track minted positions for testing
struct MintedPosition {
Stage stage;
@ -26,123 +26,100 @@ contract MockThreePositionStrategy is ThreePositionStrategy {
int24 tickUpper;
uint128 liquidity;
}
MintedPosition[] public mintedPositions;
constructor(
address _harbToken,
address _wethToken,
bool _token0IsWeth,
uint256 _ethBalance,
uint256 _outstandingSupply
) {
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) {
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) {
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 {
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
}));
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);
MockThreePositionStrategy internal strategy;
address internal constant HARB_TOKEN = address(0x1234);
address internal 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;
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,
@ -152,300 +129,301 @@ contract ThreePositionStrategyTest is TestConstants {
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);
(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");
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)))
);
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 = 79228162514264337593543950336; // 1.0 in X96 format
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 testFloorPositionEthScarcity() public {
ThreePositionStrategy.PositionParams memory params = getDefaultParams();
// Set up scenario where ETH is insufficient for VWAP price
uint256 vwapX96 = 79228162514264337593543950336 * 10; // High VWAP price
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; // 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
// Set up scenario where ETH is sufficient for VWAP price
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 to ensure abundance
strategy.setVWAP(vwapX96, 1000 ether);
uint256 largeEthBalance = 100000 ether; // Very large ETH balance
uint256 largeEthBalance = 100_000 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");
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;
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");
@ -454,22 +432,22 @@ contract ThreePositionStrategyTest is TestConstants {
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)
});
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");
}
}
}