From 6faa03867678d857bb8ecffc79aa8d567c9986ea Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Thu, 15 Jan 2026 23:15:20 -0500 Subject: [PATCH 01/16] Refactor deposit tracking --- src/LPVault.sol | 101 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index 534b455..e434b1b 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -10,7 +10,9 @@ import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; import {ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol"; import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; import {TickMath} from "v4-core/libraries/TickMath.sol"; +import {SqrtPriceMath} from "v4-core/libraries/SqrtPriceMath.sol"; import {SafeCast} from "v4-core/libraries/SafeCast.sol"; +import {FullMath} from "v4-core/libraries/FullMath.sol"; import {LiquidityAmounts} from "v4-periphery/src/libraries/LiquidityAmounts.sol"; import {FixedPoint128} from "v4-core/libraries/FixedPoint128.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; @@ -102,6 +104,9 @@ contract LPVault is VaultShareToken, IUnlockCallback { /// @notice Thrown when the total liquidity is zero. error LPVault__ZeroLiquidity(); + /// @notice Thrown when required token amounts exceed the provided maximums. + error LPVault__MaxAmountsExceeded(); + /// @notice Thrown when the shares amount is invalid (zero or exceeds balance). error LPVault__InvalidShares(); @@ -416,9 +421,9 @@ contract LPVault is VaultShareToken, IUnlockCallback { } } - /// @notice Adds liquidity to all positions based on the provided token amounts. - /// @param _amount0Max The maximum amount of token0 to distribute across positions. - /// @param _amount1Max The maximum amount of token1 to distribute across positions. + /// @notice Adds liquidity to all positions based on proportional liquidity weights. + /// @param _amount0Max The maximum amount of token0 available for the deposit. + /// @param _amount1Max The maximum amount of token1 available for the deposit. /// @return _liquidityPerPosition Array of liquidity added to each position. /// @return _totalDelta0 The total delta for token0 (negative = owed to pool). /// @return _totalDelta1 The total delta for token1 (negative = owed to pool). @@ -440,18 +445,57 @@ contract LPVault is VaultShareToken, IUnlockCallback { _liquidityPerPosition = new uint128[](positions.length); + uint16 _weight0 = positions[0].weight; + if (_weight0 == 0) revert LPVault__ZeroLiquidity(); + + uint256 _totalAmount0Ref; + uint256 _totalAmount1Ref; for (uint256 _i = 0; _i < positions.length; _i++) { LiquidityPosition memory _position = positions[_i]; + uint128 _liquidityRef = uint128(_position.weight); + (uint256 _amount0Ref, uint256 _amount1Ref) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidityRef); + _totalAmount0Ref += _amount0Ref; + _totalAmount1Ref += _amount1Ref; + } - uint256 _amount0ForPosition = (_amount0Max * _position.weight) / BIPS; - uint256 _amount1ForPosition = (_amount1Max * _position.weight) / BIPS; + if (_totalAmount0Ref == 0 && _totalAmount1Ref == 0) revert LPVault__ZeroLiquidity(); - uint128 _liquidity = _calculateLiquidityForAmounts( - _position, _sqrtPriceX96, _amount0ForPosition, _amount1ForPosition - ); + uint256 _liquidity0 = type(uint256).max; + if (_totalAmount0Ref > 0) { + _liquidity0 = FullMath.mulDiv(_amount0Max, _weight0, _totalAmount0Ref); + } + + uint256 _liquidity0ByAmount1 = type(uint256).max; + if (_totalAmount1Ref > 0) { + _liquidity0ByAmount1 = FullMath.mulDiv(_amount1Max, _weight0, _totalAmount1Ref); + } + + if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; + if (_liquidity0 == 0) revert LPVault__ZeroLiquidity(); + + uint256 _amount0Required; + uint256 _amount1Required; + + for (uint256 _i = 0; _i < positions.length; _i++) { + LiquidityPosition memory _position = positions[_i]; + + uint128 _liquidity = _i == 0 + ? _liquidity0.toUint128() + : FullMath.mulDiv(_liquidity0, _position.weight, _weight0).toUint128(); _liquidityPerPosition[_i] = _liquidity; + (uint256 _amount0ForPosition, uint256 _amount1ForPosition) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidity); + _amount0Required += _amount0ForPosition; + _amount1Required += _amount1ForPosition; + } + + for (uint256 _i = 0; _i < positions.length; _i++) { + LiquidityPosition memory _position = positions[_i]; + uint128 _liquidity = _liquidityPerPosition[_i]; + (BalanceDelta _delta, BalanceDelta _feeDelta) = POOL_MANAGER.modifyLiquidity( targetPoolKey, ModifyLiquidityParams({ @@ -468,6 +512,13 @@ contract LPVault is VaultShareToken, IUnlockCallback { _totalFeeDelta0 += _feeDelta.amount0(); _totalFeeDelta1 += _feeDelta.amount1(); } + + _amount0Required = _totalDelta0 < 0 ? uint256(-_totalDelta0) : 0; + _amount1Required = _totalDelta1 < 0 ? uint256(-_totalDelta1) : 0; + + if (_amount0Required > _amount0Max || _amount1Required > _amount1Max) { + revert LPVault__MaxAmountsExceeded(); + } } /// @notice Calculates the liquidity for a single position based on token amounts. @@ -490,6 +541,40 @@ contract LPVault is VaultShareToken, IUnlockCallback { ); } + /// @notice Calculates token amounts needed for a position and liquidity amount. + /// @param _position The liquidity position configuration. + /// @param _sqrtPriceX96 The current sqrt price of the pool. + /// @param _liquidity The liquidity to add for the position. + /// @return _amount0 The amount of token0 required. + /// @return _amount1 The amount of token1 required. + function _getAmountsForLiquidity( + LiquidityPosition memory _position, + uint160 _sqrtPriceX96, + uint128 _liquidity + ) internal pure virtual returns (uint256 _amount0, uint256 _amount1) { + if (_liquidity == 0) return (0, 0); + + uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_position.tickLower); + uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_position.tickUpper); + + if (_sqrtPriceX96 <= _sqrtPriceLowerX96) { + _amount0 = SqrtPriceMath.getAmount0Delta( + _sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true + ); + } else if (_sqrtPriceX96 >= _sqrtPriceUpperX96) { + _amount1 = SqrtPriceMath.getAmount1Delta( + _sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true + ); + } else { + _amount0 = SqrtPriceMath.getAmount0Delta( + _sqrtPriceX96, _sqrtPriceUpperX96, _liquidity, true + ); + _amount1 = SqrtPriceMath.getAmount1Delta( + _sqrtPriceLowerX96, _sqrtPriceX96, _liquidity, true + ); + } + } + /// @notice Settles token deltas with the pool manager. /// @dev Negative deltas mean we owe tokens to the pool, positive means pool owes us. /// @param _delta0 The delta for token0. From 683ed7e069d1cada9a3da7fcd982eab5f5be6a25 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Thu, 15 Jan 2026 23:50:33 -0500 Subject: [PATCH 02/16] Fix failing tests --- src/LPVault.sol | 18 ++++++------------ test/LPVault.t.sol | 8 +++----- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index e434b1b..59fec65 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -558,20 +558,14 @@ contract LPVault is VaultShareToken, IUnlockCallback { uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_position.tickUpper); if (_sqrtPriceX96 <= _sqrtPriceLowerX96) { - _amount0 = SqrtPriceMath.getAmount0Delta( - _sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true - ); + _amount0 = + SqrtPriceMath.getAmount0Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); } else if (_sqrtPriceX96 >= _sqrtPriceUpperX96) { - _amount1 = SqrtPriceMath.getAmount1Delta( - _sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true - ); + _amount1 = + SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); } else { - _amount0 = SqrtPriceMath.getAmount0Delta( - _sqrtPriceX96, _sqrtPriceUpperX96, _liquidity, true - ); - _amount1 = SqrtPriceMath.getAmount1Delta( - _sqrtPriceLowerX96, _sqrtPriceX96, _liquidity, true - ); + _amount0 = SqrtPriceMath.getAmount0Delta(_sqrtPriceX96, _sqrtPriceUpperX96, _liquidity, true); + _amount1 = SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceX96, _liquidity, true); } } diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 11e1425..21c735a 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -188,9 +188,8 @@ contract LPVaultTest is Test, Deployers { contract Constructor is LPVaultTest { function testFuzz_SinglePosition(int24 _tickLower, int24 _tickUpper, address _deployer) public { - _tickLower = _boundTick(_tickLower); - _tickUpper = _boundTick(_tickUpper); - vm.assume(_tickLower < _tickUpper); + _tickLower = int24(bound(int256(_tickLower), int256(MIN_TICK), int256(MAX_TICK - 1))); + _tickUpper = int24(bound(int256(_tickUpper), int256(_tickLower + 1), int256(MAX_TICK))); vm.assume(_deployer != address(0)); LPVault.LiquidityPosition[] memory _positions = new LPVault.LiquidityPosition[](1); @@ -311,8 +310,7 @@ contract Constructor is LPVaultTest { function testFuzz_RevertIf_InvalidTickRange(int24 _tickLower, int24 _tickUpper) public { _tickLower = _boundTick(_tickLower); - _tickUpper = _boundTick(_tickUpper); - vm.assume(_tickLower >= _tickUpper); + _tickUpper = int24(bound(int256(_tickUpper), int256(MIN_TICK), int256(_tickLower))); LPVault.LiquidityPosition[] memory _positions = new LPVault.LiquidityPosition[](1); _positions[0] = LPVault.LiquidityPosition({ From d0d3abc846b1616e1199dec3d66394ce92caa706 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Fri, 16 Jan 2026 00:02:25 -0500 Subject: [PATCH 03/16] Update tests --- src/LPVault.sol | 64 +++++++++-------------- test/LPVault.t.sol | 124 +++++++++++++++++++++++++++------------------ 2 files changed, 100 insertions(+), 88 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index 59fec65..a4c9c66 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -440,43 +440,39 @@ contract LPVault is VaultShareToken, IUnlockCallback { int256 _totalFeeDelta1 ) { - PoolId _poolId = targetPoolKey.toId(); - (uint160 _sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(_poolId); + (uint160 _sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(targetPoolKey.toId()); _liquidityPerPosition = new uint128[](positions.length); uint16 _weight0 = positions[0].weight; if (_weight0 == 0) revert LPVault__ZeroLiquidity(); - uint256 _totalAmount0Ref; - uint256 _totalAmount1Ref; - for (uint256 _i = 0; _i < positions.length; _i++) { - LiquidityPosition memory _position = positions[_i]; - uint128 _liquidityRef = uint128(_position.weight); - (uint256 _amount0Ref, uint256 _amount1Ref) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidityRef); - _totalAmount0Ref += _amount0Ref; - _totalAmount1Ref += _amount1Ref; - } + uint256 _liquidity0; + { + uint256 _totalAmount0Ref; + uint256 _totalAmount1Ref; + for (uint256 _i = 0; _i < positions.length; _i++) { + LiquidityPosition memory _position = positions[_i]; + (uint256 _amount0Ref, uint256 _amount1Ref) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_position.weight)); + _totalAmount0Ref += _amount0Ref; + _totalAmount1Ref += _amount1Ref; + } - if (_totalAmount0Ref == 0 && _totalAmount1Ref == 0) revert LPVault__ZeroLiquidity(); + if (_totalAmount0Ref == 0 && _totalAmount1Ref == 0) revert LPVault__ZeroLiquidity(); - uint256 _liquidity0 = type(uint256).max; - if (_totalAmount0Ref > 0) { - _liquidity0 = FullMath.mulDiv(_amount0Max, _weight0, _totalAmount0Ref); - } + _liquidity0 = type(uint256).max; + if (_totalAmount0Ref > 0) { + _liquidity0 = FullMath.mulDiv(_amount0Max, _weight0, _totalAmount0Ref); + } - uint256 _liquidity0ByAmount1 = type(uint256).max; - if (_totalAmount1Ref > 0) { - _liquidity0ByAmount1 = FullMath.mulDiv(_amount1Max, _weight0, _totalAmount1Ref); + if (_totalAmount1Ref > 0) { + uint256 _liquidity0ByAmount1 = FullMath.mulDiv(_amount1Max, _weight0, _totalAmount1Ref); + if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; + } } - - if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; if (_liquidity0 == 0) revert LPVault__ZeroLiquidity(); - uint256 _amount0Required; - uint256 _amount1Required; - for (uint256 _i = 0; _i < positions.length; _i++) { LiquidityPosition memory _position = positions[_i]; @@ -486,16 +482,6 @@ contract LPVault is VaultShareToken, IUnlockCallback { _liquidityPerPosition[_i] = _liquidity; - (uint256 _amount0ForPosition, uint256 _amount1ForPosition) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidity); - _amount0Required += _amount0ForPosition; - _amount1Required += _amount1ForPosition; - } - - for (uint256 _i = 0; _i < positions.length; _i++) { - LiquidityPosition memory _position = positions[_i]; - uint128 _liquidity = _liquidityPerPosition[_i]; - (BalanceDelta _delta, BalanceDelta _feeDelta) = POOL_MANAGER.modifyLiquidity( targetPoolKey, ModifyLiquidityParams({ @@ -513,10 +499,10 @@ contract LPVault is VaultShareToken, IUnlockCallback { _totalFeeDelta1 += _feeDelta.amount1(); } - _amount0Required = _totalDelta0 < 0 ? uint256(-_totalDelta0) : 0; - _amount1Required = _totalDelta1 < 0 ? uint256(-_totalDelta1) : 0; - - if (_amount0Required > _amount0Max || _amount1Required > _amount1Max) { + if (_totalDelta0 < 0 && uint256(-_totalDelta0) > _amount0Max) { + revert LPVault__MaxAmountsExceeded(); + } + if (_totalDelta1 < 0 && uint256(-_totalDelta1) > _amount1Max) { revert LPVault__MaxAmountsExceeded(); } } diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 21c735a..c3b1b8b 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -14,9 +14,8 @@ import {IHooks} from "v4-core/interfaces/IHooks.sol"; import {PoolIdLibrary} from "v4-core/types/PoolId.sol"; import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; import {TickMath} from "v4-core/libraries/TickMath.sol"; -import {LiquidityAmounts} from "v4-periphery/src/libraries/LiquidityAmounts.sol"; +import {FullMath} from "v4-core/libraries/FullMath.sol"; import {SqrtPriceMath} from "v4-core/libraries/SqrtPriceMath.sol"; -import {Position} from "v4-core/libraries/Position.sol"; import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; contract LPVaultTest is Test, Deployers { @@ -343,6 +342,28 @@ contract Deposit is LPVaultTest { vm.stopPrank(); } + function _getAmountsForLiquidity( + LPVault.LiquidityPosition memory _position, + uint160 _sqrtPriceX96, + uint128 _liquidity + ) internal pure returns (uint256 _amount0, uint256 _amount1) { + if (_liquidity == 0) return (0, 0); + + uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_position.tickLower); + uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_position.tickUpper); + + if (_sqrtPriceX96 <= _sqrtPriceLowerX96) { + _amount0 = + SqrtPriceMath.getAmount0Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); + } else if (_sqrtPriceX96 >= _sqrtPriceUpperX96) { + _amount1 = + SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); + } else { + _amount0 = SqrtPriceMath.getAmount0Delta(_sqrtPriceX96, _sqrtPriceUpperX96, _liquidity, true); + _amount1 = SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceX96, _liquidity, true); + } + } + /// @dev Calculates expected deposit outputs (shares = first position's liquidity). function _calculateExpectedDeposit(uint256 _amount0, uint256 _amount1) internal @@ -352,46 +373,41 @@ contract Deposit is LPVaultTest { PoolId _poolId = defaultPoolKey.toId(); (uint160 _sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, _poolId); + uint256 _totalAmount0Ref; + uint256 _totalAmount1Ref; + uint16 _weight0 = defaultPositions[0].weight; + for (uint256 _i = 0; _i < defaultPositions.length; _i++) { LPVault.LiquidityPosition memory _position = defaultPositions[_i]; + (uint256 _amount0Ref, uint256 _amount1Ref) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_position.weight)); + _totalAmount0Ref += _amount0Ref; + _totalAmount1Ref += _amount1Ref; + } - uint256 _amount0ForPosition = (_amount0 * _position.weight) / BIPS; - uint256 _amount1ForPosition = (_amount1 * _position.weight) / BIPS; + uint256 _liquidity0 = type(uint256).max; + if (_totalAmount0Ref > 0) { + _liquidity0 = FullMath.mulDiv(_amount0, _weight0, _totalAmount0Ref); + } - uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_position.tickLower); - uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_position.tickUpper); + uint256 _liquidity0ByAmount1 = type(uint256).max; + if (_totalAmount1Ref > 0) { + _liquidity0ByAmount1 = FullMath.mulDiv(_amount1, _weight0, _totalAmount1Ref); + } - uint128 _liquidity = LiquidityAmounts.getLiquidityForAmounts( - _sqrtPriceX96, - _sqrtPriceLowerX96, - _sqrtPriceUpperX96, - _amount0ForPosition, - _amount1ForPosition - ); + if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; - // Calculate amounts needed for this liquidity based on position's range relative to current - // price. - uint256 _amount0delta; - uint256 _amount1delta; + _expectedShares = uint128(_liquidity0); - if (_sqrtPriceX96 <= _sqrtPriceLowerX96) { - // Current price below position - only token0 needed. - _amount0delta = - SqrtPriceMath.getAmount0Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); - } else if (_sqrtPriceX96 >= _sqrtPriceUpperX96) { - // Current price above position - only token1 needed. - _amount1delta = - SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); - } else { - // In range - both tokens needed. - _amount0delta = - SqrtPriceMath.getAmount0Delta(_sqrtPriceX96, _sqrtPriceUpperX96, _liquidity, true); - _amount1delta = - SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceX96, _liquidity, true); - } + for (uint256 _i = 0; _i < defaultPositions.length; _i++) { + LPVault.LiquidityPosition memory _position = defaultPositions[_i]; + uint128 _liquidity = _i == 0 + ? _expectedShares + : uint128(FullMath.mulDiv(_liquidity0, _position.weight, _weight0)); + + (uint256 _amount0delta, uint256 _amount1delta) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidity); - // Shares are based on first position's liquidity only. - if (_i == 0) _expectedShares = _liquidity; _expectedAmount0 += _amount0delta; _expectedAmount1 += _amount1delta; } @@ -445,22 +461,32 @@ contract Deposit is LPVaultTest { // Calculate expected liquidity for each position. uint128[] memory _expectedLiquidity = new uint128[](defaultPositions.length); + uint16 _weight0 = defaultPositions[0].weight; + uint256 _totalAmount0Ref; + uint256 _totalAmount1Ref; for (uint256 _i = 0; _i < defaultPositions.length; _i++) { LPVault.LiquidityPosition memory _position = defaultPositions[_i]; + (uint256 _amount0Ref, uint256 _amount1Ref) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_position.weight)); + _totalAmount0Ref += _amount0Ref; + _totalAmount1Ref += _amount1Ref; + } - uint256 _amount0ForPosition = (_amount0 * _position.weight) / BIPS; - uint256 _amount1ForPosition = (_amount1 * _position.weight) / BIPS; - - uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_position.tickLower); - uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_position.tickUpper); + uint256 _liquidity0 = type(uint256).max; + if (_totalAmount0Ref > 0) { + _liquidity0 = FullMath.mulDiv(_amount0, _weight0, _totalAmount0Ref); + } + uint256 _liquidity0ByAmount1 = type(uint256).max; + if (_totalAmount1Ref > 0) { + _liquidity0ByAmount1 = FullMath.mulDiv(_amount1, _weight0, _totalAmount1Ref); + } + if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; - _expectedLiquidity[_i] = LiquidityAmounts.getLiquidityForAmounts( - _sqrtPriceX96, - _sqrtPriceLowerX96, - _sqrtPriceUpperX96, - _amount0ForPosition, - _amount1ForPosition - ); + for (uint256 _i = 0; _i < defaultPositions.length; _i++) { + LPVault.LiquidityPosition memory _position = defaultPositions[_i]; + _expectedLiquidity[_i] = _i == 0 + ? uint128(_liquidity0) + : uint128(FullMath.mulDiv(_liquidity0, _position.weight, _weight0)); } vm.prank(depositor); @@ -514,7 +540,7 @@ contract Deposit is LPVaultTest { function test_RevertIf_ZeroLiquidity() public { vm.prank(depositor); - vm.expectRevert(Position.CannotUpdateEmptyPosition.selector); + vm.expectRevert(LPVault.LPVault__ZeroLiquidity.selector); vault.deposit(0, 0); } @@ -697,8 +723,8 @@ contract Withdraw is LPVaultTest { assertEq(vault.totalSupply(), 0); // Verify amounts received match total deposited (within rounding tolerance). - assertApproxEqAbs(_amount0Received, _amount0Used1, 3); - assertApproxEqAbs(_amount1Received, _amount1Used1, 3); + assertApproxEqAbs(_amount0Received, _amount0Used1, 5); + assertApproxEqAbs(_amount1Received, _amount1Used1, 5); // Verify liquidity was proportionally removed (all positions should be empty). for (uint256 _i = 0; _i < defaultPositions.length; _i++) { From 88d53b81fe078310c47117a042bccb885e0d7a4e Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Mon, 19 Jan 2026 20:54:18 -0500 Subject: [PATCH 04/16] Use libs where applicable --- src/LPVault.sol | 69 +++++++++++++--------------------------------- test/LPVault.t.sol | 3 +- 2 files changed, 21 insertions(+), 51 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index a4c9c66..8f953a8 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -5,17 +5,18 @@ import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; import {Currency} from "v4-core/types/Currency.sol"; import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; -import {IUnlockCallback} from "v4-core/interfaces/callback/IUnlockCallback.sol"; -import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; +import {BalanceDelta, toBalanceDelta} from "v4-core/types/BalanceDelta.sol"; import {ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol"; import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; import {TickMath} from "v4-core/libraries/TickMath.sol"; import {SqrtPriceMath} from "v4-core/libraries/SqrtPriceMath.sol"; import {SafeCast} from "v4-core/libraries/SafeCast.sol"; import {FullMath} from "v4-core/libraries/FullMath.sol"; -import {LiquidityAmounts} from "v4-periphery/src/libraries/LiquidityAmounts.sol"; import {FixedPoint128} from "v4-core/libraries/FixedPoint128.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeCallback} from "v4-periphery/src/base/SafeCallback.sol"; +import {DeltaResolver} from "v4-periphery/src/base/DeltaResolver.sol"; +import {SlippageCheck} from "v4-periphery/src/libraries/SlippageCheck.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {VaultShareToken} from "src/VaultShareToken.sol"; @@ -24,10 +25,11 @@ import {VaultShareToken} from "src/VaultShareToken.sol"; /// @author [ScopeLift](https://scopelift.co) /// @notice A contract that wraps a set of static LP positions in a single Uniswap v4 pool. /// @dev Positions and their weight distributions are fixed at contract creation. -contract LPVault is VaultShareToken, IUnlockCallback { +contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; using SafeCast for uint256; + using SafeCast for int256; using SafeERC20 for IERC20; /// @notice Structure for liquidity position configuration. @@ -104,15 +106,9 @@ contract LPVault is VaultShareToken, IUnlockCallback { /// @notice Thrown when the total liquidity is zero. error LPVault__ZeroLiquidity(); - /// @notice Thrown when required token amounts exceed the provided maximums. - error LPVault__MaxAmountsExceeded(); - /// @notice Thrown when the shares amount is invalid (zero or exceeds balance). error LPVault__InvalidShares(); - /// @notice Thrown when the callback is called by a non-pool manager. - error LPVault__OnlyPoolManager(); - /// @notice Emitted when the pool manager is set during construction. /// @param poolManager The address of the Uniswap v4 pool manager. event PoolManagerSet(address indexed poolManager); @@ -162,6 +158,7 @@ contract LPVault is VaultShareToken, IUnlockCallback { Currency.unwrap(_targetPoolKey.currency0), Currency.unwrap(_targetPoolKey.currency1) ) + SafeCallback(_poolManager) { POOL_MANAGER = _poolManager; emit PoolManagerSet(address(_poolManager)); @@ -306,10 +303,8 @@ contract LPVault is VaultShareToken, IUnlockCallback { emit FeesCollected(_collectedFee0, _collectedFee1); } - /// @inheritdoc IUnlockCallback - function unlockCallback(bytes calldata _data) external virtual returns (bytes memory) { - if (msg.sender != address(POOL_MANAGER)) revert LPVault__OnlyPoolManager(); - + /// @inheritdoc SafeCallback + function _unlockCallback(bytes calldata _data) internal virtual override returns (bytes memory) { (CallbackAction _action, bytes memory _actionData) = abi.decode(_data, (CallbackAction, bytes)); if (_action == CallbackAction.Deposit) return _handleDepositCallback(_actionData); @@ -499,31 +494,10 @@ contract LPVault is VaultShareToken, IUnlockCallback { _totalFeeDelta1 += _feeDelta.amount1(); } - if (_totalDelta0 < 0 && uint256(-_totalDelta0) > _amount0Max) { - revert LPVault__MaxAmountsExceeded(); - } - if (_totalDelta1 < 0 && uint256(-_totalDelta1) > _amount1Max) { - revert LPVault__MaxAmountsExceeded(); - } - } - - /// @notice Calculates the liquidity for a single position based on token amounts. - /// @param _position The liquidity position configuration. - /// @param _sqrtPriceX96 The current sqrt price of the pool. - /// @param _amount0 The amount of token0 for this position. - /// @param _amount1 The amount of token1 for this position. - /// @return _liquidity The calculated liquidity for this position. - function _calculateLiquidityForAmounts( - LiquidityPosition memory _position, - uint160 _sqrtPriceX96, - uint256 _amount0, - uint256 _amount1 - ) internal pure virtual returns (uint128 _liquidity) { - uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_position.tickLower); - uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_position.tickUpper); - - _liquidity = LiquidityAmounts.getLiquidityForAmounts( - _sqrtPriceX96, _sqrtPriceLowerX96, _sqrtPriceUpperX96, _amount0, _amount1 + SlippageCheck.validateMaxIn( + toBalanceDelta(_totalDelta0.toInt128(), _totalDelta1.toInt128()), + _amount0Max.toUint128(), + _amount1Max.toUint128() ); } @@ -570,22 +544,17 @@ contract LPVault is VaultShareToken, IUnlockCallback { // Settle tokens owed to the pool (negative delta). if (_delta0 < 0) { _amount0Used = uint256(-_delta0); - _settleToken(targetPoolKey.currency0, _payer, _amount0Used); + _settle(targetPoolKey.currency0, _payer, _amount0Used); } if (_delta1 < 0) { _amount1Used = uint256(-_delta1); - _settleToken(targetPoolKey.currency1, _payer, _amount1Used); + _settle(targetPoolKey.currency1, _payer, _amount1Used); } } - /// @notice Settles a token payment to the pool manager. - /// @param _currency The currency to settle. - /// @param _payer The address paying the tokens. - /// @param _amount The amount to settle. - function _settleToken(Currency _currency, address _payer, uint256 _amount) internal virtual { - POOL_MANAGER.sync(_currency); + /// @inheritdoc DeltaResolver + function _pay(Currency _currency, address _payer, uint256 _amount) internal virtual override { IERC20(Currency.unwrap(_currency)).safeTransferFrom(_payer, address(POOL_MANAGER), _amount); - POOL_MANAGER.settle(); } /// @notice Removes liquidity from all positions based on the provided amounts. @@ -644,11 +613,11 @@ contract LPVault is VaultShareToken, IUnlockCallback { // Take tokens owed to us by the pool (positive delta). if (_delta0 > 0) { _amount0Received = uint256(_delta0); - POOL_MANAGER.take(targetPoolKey.currency0, _receiver, _amount0Received); + _take(targetPoolKey.currency0, _receiver, _amount0Received); } if (_delta1 > 0) { _amount1Received = uint256(_delta1); - POOL_MANAGER.take(targetPoolKey.currency1, _receiver, _amount1Received); + _take(targetPoolKey.currency1, _receiver, _amount1Received); } } diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index c3b1b8b..3b94650 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -7,6 +7,7 @@ import {Deployers} from "lib/v4-periphery/lib/v4-core/test/utils/Deployers.sol"; import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; import {LPVault} from "src/LPVault.sol"; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {ImmutableState} from "v4-periphery/src/base/ImmutableState.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; import {PoolId} from "v4-core/types/PoolId.sol"; import {Currency} from "v4-core/types/Currency.sol"; @@ -556,7 +557,7 @@ contract Deposit is LPVaultTest { }); vm.prank(_caller); - vm.expectRevert(LPVault.LPVault__OnlyPoolManager.selector); + vm.expectRevert(ImmutableState.NotPoolManager.selector); vault.unlockCallback(abi.encode(LPVault.CallbackAction.Deposit, abi.encode(_callbackData))); } From c7f122fba61714c8a0bff1aafa0470c75ba98dd3 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Wed, 21 Jan 2026 16:57:47 -0500 Subject: [PATCH 05/16] Reference weight during init, then keep existing ratio when depositing --- src/LPVault.sol | 252 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 176 insertions(+), 76 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index 8f953a8..96752a7 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -56,7 +56,8 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { enum CallbackAction { Deposit, Withdraw, - CollectFees + CollectFees, + InitializeLiquidity } /// @notice Struct for passing deposit callback data. @@ -64,6 +65,7 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { address sender; uint256 amount0Max; uint256 amount1Max; + uint128[] liquidityPerPosition; } /// @notice Struct for passing withdraw callback data. @@ -94,6 +96,9 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { /// @dev Indexed by position index in `positions`. mapping(uint256 => PositionCheckpoint) internal positionCheckpoints; + /// @notice Whether liquidity has been initialized. + bool public liquidityInitialized; + /// @notice Thrown when the sum of position weights doesn't equal BIPS (10,000 BIPs). error LPVault__InvalidLiquidityDistribution(); @@ -109,6 +114,12 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { /// @notice Thrown when the shares amount is invalid (zero or exceeds balance). error LPVault__InvalidShares(); + /// @notice Thrown when liquidity has already been initialized. + error LPVault__AlreadyInitialized(); + + /// @notice Thrown when attempting deposit before initialization. + error LPVault__NotInitialized(); + /// @notice Emitted when the pool manager is set during construction. /// @param poolManager The address of the Uniswap v4 pool manager. event PoolManagerSet(address indexed poolManager); @@ -191,52 +202,110 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { if (_totalWeight != BIPS) revert LPVault__InvalidLiquidityDistribution(); } - /// @notice Deposits tokens into the vault, distributing liquidity across positions by weight. - /// @param _amount0Max The maximum amount of token0 to deposit across all positions. - /// @param _amount1Max The maximum amount of token1 to deposit across all positions. - /// @dev Mints the maximum shares possible given the provided token amounts. Any tokens not used - /// for liquidity provision will be returned to the depositor. + /// @notice Initializes liquidity for all positions based on configured weights. Can only be called once. + /// @param _amount0Max Maximum amount of token0 willing to provide. + /// @param _amount1Max Maximum amount of token1 willing to provide. /// @return _shares The amount of vault shares minted. /// @return _amount0Used The actual amount of token0 used. /// @return _amount1Used The actual amount of token1 used. - function deposit(uint256 _amount0Max, uint256 _amount1Max) + function initializeLiquidity(uint256 _amount0Max, uint256 _amount1Max) external virtual returns (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) { - // Sync fee accounting using the current liquidity configuration before changing it. + if (liquidityInitialized) revert LPVault__AlreadyInitialized(); + + // Sync checkpoints before first liquidity add so pre-existing pool fees are not attributed. _syncFees(); + (uint128[] memory _liquidityPerPosition, uint128 _liquidity0) = + _calculateInitialLiquidity(_amount0Max, _amount1Max); + + liquidityInitialized = true; + bytes memory _result = POOL_MANAGER.unlock( abi.encode( - CallbackAction.Deposit, + CallbackAction.InitializeLiquidity, abi.encode( DepositCallbackData({ - sender: msg.sender, amount0Max: _amount0Max, amount1Max: _amount1Max + sender: msg.sender, + amount0Max: _amount0Max, + amount1Max: _amount1Max, + liquidityPerPosition: _liquidityPerPosition }) ) ) ); - (uint128[] memory _liquidityPerPosition, uint256 _amount0, uint256 _amount1) = - abi.decode(_result, (uint128[], uint256, uint256)); + (_amount0Used, _amount1Used) = abi.decode(_result, (uint256, uint256)); - uint128 _newLiquidity = _liquidityPerPosition[0]; - if (_newLiquidity == 0) revert LPVault__ZeroLiquidity(); + // Update position liquidity tracking + for (uint256 _i = 0; _i < _liquidityPerPosition.length; _i++) { + positions[_i].liquidity = _liquidityPerPosition[_i]; + } - // Use the first position's liquidity as the share amount since it serves as the reference - // for proportional distribution across all positions based on their weights. - _shares = uint256(_newLiquidity); + // Mint shares equal to position0's liquidity + _shares = uint256(_liquidity0); + _mint(msg.sender, _shares); + + emit Deposited(msg.sender, _shares, _amount0Used, _amount1Used); + } + + /// @notice Deposits tokens into the vault by specifying desired position0 liquidity. + /// @param _amount0Max The maximum amount of token0 to deposit across all positions. + /// @param _amount1Max The maximum amount of token1 to deposit across all positions. + /// @param _liquidity The liquidity to add to position0. Other positions calculated proportionally. + /// @dev Requires liquidity to be initialized first via initializeLiquidity(). + /// @return _shares The amount of vault shares minted (equal to _liquidity). + /// @return _amount0Used The actual amount of token0 used. + /// @return _amount1Used The actual amount of token1 used. + function deposit(uint256 _amount0Max, uint256 _amount1Max, uint128 _liquidity) + external + virtual + returns (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) + { + if (!liquidityInitialized) revert LPVault__NotInitialized(); + if (_liquidity == 0) revert LPVault__ZeroLiquidity(); + + // Calculate liquidity for each position based on current ratios + uint128[] memory _liquidityPerPosition = new uint128[](positions.length); + uint128 _position0Liquidity = positions[0].liquidity; + + for (uint256 _i = 0; _i < positions.length; _i++) { + if (_i == 0) { + _liquidityPerPosition[0] = _liquidity; + } else { + // positionN.liquidity / position0.liquidity * requested_liquidity + _liquidityPerPosition[_i] = + FullMath.mulDiv(_liquidity, positions[_i].liquidity, _position0Liquidity).toUint128(); + } + } + + bytes memory _result = POOL_MANAGER.unlock( + abi.encode( + CallbackAction.Deposit, + abi.encode( + DepositCallbackData({ + sender: msg.sender, + amount0Max: _amount0Max, + amount1Max: _amount1Max, + liquidityPerPosition: _liquidityPerPosition + }) + ) + ) + ); + + (_amount0Used, _amount1Used) = abi.decode(_result, (uint256, uint256)); // Update per-position liquidity. for (uint256 _i = 0; _i < _liquidityPerPosition.length; _i++) { positions[_i].liquidity += _liquidityPerPosition[_i]; } + // Mint shares equal to position0's liquidity added + _shares = uint256(_liquidity); _mint(msg.sender, _shares); - _amount0Used = _amount0; - _amount1Used = _amount1; emit Deposited(msg.sender, _shares, _amount0Used, _amount1Used); } @@ -260,16 +329,23 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { // Calculate liquidity to remove from each position. // Position 0 uses shares directly (1:1 mapping), while other positions - // are calculated proportionally based on their liquidity relative to total supply. + // are calculated proportionally based on their liquidity relative to position0's liquidity. + uint128 _position0Liquidity = positions[0].liquidity; for (uint256 _i = 0; _i < positions.length; _i++) { if (_i == 0) { _liquidityToRemove[0] = uint128(_shares); } else { - _liquidityToRemove[_i] = uint128((_shares * positions[_i].liquidity) / totalSupply()); + _liquidityToRemove[_i] = + FullMath.mulDiv(_shares, positions[_i].liquidity, _position0Liquidity).toUint128(); } positions[_i].liquidity -= _liquidityToRemove[_i]; } + // If all liquidity is withdrawn, reset initialization to allow re-initialization. + if (positions[0].liquidity == 0) { + liquidityInitialized = false; + } + // Burn shares before making external calls (reentrancy protection). _burn(msg.sender, _shares); @@ -309,6 +385,7 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { if (_action == CallbackAction.Deposit) return _handleDepositCallback(_actionData); if (_action == CallbackAction.Withdraw) return _handleWithdrawCallback(_actionData); + if (_action == CallbackAction.InitializeLiquidity) return _handleInitializeLiquidityCallback(_actionData); return _handleCollectFeesCallback(); } @@ -322,7 +399,7 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { /// @notice Handles the deposit callback logic. /// @param _actionData The encoded deposit callback data. - /// @return The encoded result containing liquidity per position and amounts used. + /// @return The encoded result containing amounts used. function _handleDepositCallback(bytes memory _actionData) internal virtual @@ -330,13 +407,10 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { { DepositCallbackData memory _callbackData = abi.decode(_actionData, (DepositCallbackData)); - ( - uint128[] memory _liquidityPerPosition, - int256 _totalDelta0, - int256 _totalDelta1, - int256 _totalFeeDelta0, - int256 _totalFeeDelta1 - ) = _addLiquidityToPositions(_callbackData.amount0Max, _callbackData.amount1Max); + (int256 _totalDelta0, int256 _totalDelta1, int256 _totalFeeDelta0, int256 _totalFeeDelta1) = + _addLiquidityToPositions( + _callbackData.liquidityPerPosition, _callbackData.amount0Max, _callbackData.amount1Max + ); (uint256 _amount0Used, uint256 _amount1Used) = _settleDeltas(_totalDelta0, _totalDelta1, _callbackData.sender); @@ -344,7 +418,27 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { // Collect fees and update fee-per-share indexes. _collectFees(_totalFeeDelta0, _totalFeeDelta1); - return abi.encode(_liquidityPerPosition, _amount0Used, _amount1Used); + return abi.encode(_amount0Used, _amount1Used); + } + + /// @notice Handles the initialize liquidity callback logic. + /// @param _actionData The encoded callback data. + /// @return The encoded result containing amounts used. + function _handleInitializeLiquidityCallback(bytes memory _actionData) + internal + virtual + returns (bytes memory) + { + DepositCallbackData memory _callbackData = abi.decode(_actionData, (DepositCallbackData)); + + (int256 _totalDelta0, int256 _totalDelta1,,) = _addLiquidityToPositions( + _callbackData.liquidityPerPosition, _callbackData.amount0Max, _callbackData.amount1Max + ); + + (uint256 _amount0Used, uint256 _amount1Used) = + _settleDeltas(_totalDelta0, _totalDelta1, _callbackData.sender); + + return abi.encode(_amount0Used, _amount1Used); } /// @notice Handles the withdraw callback logic. @@ -416,66 +510,72 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { } } - /// @notice Adds liquidity to all positions based on proportional liquidity weights. - /// @param _amount0Max The maximum amount of token0 available for the deposit. - /// @param _amount1Max The maximum amount of token1 available for the deposit. - /// @return _liquidityPerPosition Array of liquidity added to each position. - /// @return _totalDelta0 The total delta for token0 (negative = owed to pool). - /// @return _totalDelta1 The total delta for token1 (negative = owed to pool). - /// @return _totalFeeDelta0 The total fee delta for token0 (positive = fees earned). - /// @return _totalFeeDelta1 The total fee delta for token1 (positive = fees earned). - function _addLiquidityToPositions(uint256 _amount0Max, uint256 _amount1Max) + /// @notice Calculates initial per-position liquidity based on weights and max token amounts. + /// @param _amount0Max Maximum amount of token0 willing to provide. + /// @param _amount1Max Maximum amount of token1 willing to provide. + /// @return _liquidityPerPosition Array of liquidity amounts for each position. + /// @return _liquidity0 The liquidity assigned to position0. + function _calculateInitialLiquidity(uint256 _amount0Max, uint256 _amount1Max) internal - virtual - returns ( - uint128[] memory _liquidityPerPosition, - int256 _totalDelta0, - int256 _totalDelta1, - int256 _totalFeeDelta0, - int256 _totalFeeDelta1 - ) + view + returns (uint128[] memory _liquidityPerPosition, uint128 _liquidity0) { (uint160 _sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(targetPoolKey.toId()); - _liquidityPerPosition = new uint128[](positions.length); - uint16 _weight0 = positions[0].weight; if (_weight0 == 0) revert LPVault__ZeroLiquidity(); - uint256 _liquidity0; - { - uint256 _totalAmount0Ref; - uint256 _totalAmount1Ref; - for (uint256 _i = 0; _i < positions.length; _i++) { - LiquidityPosition memory _position = positions[_i]; - (uint256 _amount0Ref, uint256 _amount1Ref) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_position.weight)); - _totalAmount0Ref += _amount0Ref; - _totalAmount1Ref += _amount1Ref; - } + uint256 _totalAmount0Ref; + uint256 _totalAmount1Ref; + for (uint256 _i = 0; _i < positions.length; _i++) { + LiquidityPosition memory _position = positions[_i]; + (uint256 _amount0Ref, uint256 _amount1Ref) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_position.weight)); + _totalAmount0Ref += _amount0Ref; + _totalAmount1Ref += _amount1Ref; + } - if (_totalAmount0Ref == 0 && _totalAmount1Ref == 0) revert LPVault__ZeroLiquidity(); + if (_totalAmount0Ref == 0 && _totalAmount1Ref == 0) revert LPVault__ZeroLiquidity(); - _liquidity0 = type(uint256).max; - if (_totalAmount0Ref > 0) { - _liquidity0 = FullMath.mulDiv(_amount0Max, _weight0, _totalAmount0Ref); - } + uint256 _liquidity0Full = type(uint256).max; + if (_totalAmount0Ref > 0) { + _liquidity0Full = FullMath.mulDiv(_amount0Max, _weight0, _totalAmount0Ref); + } + if (_totalAmount1Ref > 0) { + uint256 _liquidity0ByAmount1 = FullMath.mulDiv(_amount1Max, _weight0, _totalAmount1Ref); + if (_liquidity0ByAmount1 < _liquidity0Full) _liquidity0Full = _liquidity0ByAmount1; + } + if (_liquidity0Full == 0) revert LPVault__ZeroLiquidity(); - if (_totalAmount1Ref > 0) { - uint256 _liquidity0ByAmount1 = FullMath.mulDiv(_amount1Max, _weight0, _totalAmount1Ref); - if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; - } + _liquidity0 = _liquidity0Full.toUint128(); + _liquidityPerPosition = new uint128[](positions.length); + _liquidityPerPosition[0] = _liquidity0; + for (uint256 _i = 1; _i < positions.length; _i++) { + _liquidityPerPosition[_i] = + FullMath.mulDiv(_liquidity0Full, positions[_i].weight, _weight0).toUint128(); } - if (_liquidity0 == 0) revert LPVault__ZeroLiquidity(); + } + /// @notice Adds liquidity to all positions using pre-calculated amounts. + /// @param _liquidityPerPosition Array of liquidity to add to each position. + /// @param _amount0Max Maximum token0 for slippage protection. + /// @param _amount1Max Maximum token1 for slippage protection. + /// @return _totalDelta0 The total delta for token0 (negative = owed to pool). + /// @return _totalDelta1 The total delta for token1 (negative = owed to pool). + /// @return _totalFeeDelta0 The total fee delta for token0 (positive = fees earned). + /// @return _totalFeeDelta1 The total fee delta for token1 (positive = fees earned). + function _addLiquidityToPositions( + uint128[] memory _liquidityPerPosition, + uint256 _amount0Max, + uint256 _amount1Max + ) + internal + virtual + returns (int256 _totalDelta0, int256 _totalDelta1, int256 _totalFeeDelta0, int256 _totalFeeDelta1) + { for (uint256 _i = 0; _i < positions.length; _i++) { LiquidityPosition memory _position = positions[_i]; - - uint128 _liquidity = _i == 0 - ? _liquidity0.toUint128() - : FullMath.mulDiv(_liquidity0, _position.weight, _weight0).toUint128(); - - _liquidityPerPosition[_i] = _liquidity; + uint128 _liquidity = _liquidityPerPosition[_i]; (BalanceDelta _delta, BalanceDelta _feeDelta) = POOL_MANAGER.modifyLiquidity( targetPoolKey, From 88db9c9c4fc625ab0dbfe5ddd58971d6db008147 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Tue, 27 Jan 2026 00:56:13 -0500 Subject: [PATCH 06/16] Update tests --- src/LPVault.sol | 16 +- test/LPVault.t.sol | 612 +++++++++++++++++++++++++++++---------------- 2 files changed, 403 insertions(+), 225 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index 96752a7..bd6e20e 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -221,8 +221,6 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { (uint128[] memory _liquidityPerPosition, uint128 _liquidity0) = _calculateInitialLiquidity(_amount0Max, _amount1Max); - liquidityInitialized = true; - bytes memory _result = POOL_MANAGER.unlock( abi.encode( CallbackAction.InitializeLiquidity, @@ -248,6 +246,8 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { _shares = uint256(_liquidity0); _mint(msg.sender, _shares); + liquidityInitialized = true; + emit Deposited(msg.sender, _shares, _amount0Used, _amount1Used); } @@ -527,22 +527,24 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { uint256 _totalAmount0Ref; uint256 _totalAmount1Ref; + uint256 _liquidityReference = 1e18; for (uint256 _i = 0; _i < positions.length; _i++) { LiquidityPosition memory _position = positions[_i]; (uint256 _amount0Ref, uint256 _amount1Ref) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_position.weight)); - _totalAmount0Ref += _amount0Ref; - _totalAmount1Ref += _amount1Ref; + _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_liquidityReference)); + _totalAmount0Ref += _amount0Ref * _position.weight; + _totalAmount1Ref += _amount1Ref * _position.weight; } if (_totalAmount0Ref == 0 && _totalAmount1Ref == 0) revert LPVault__ZeroLiquidity(); uint256 _liquidity0Full = type(uint256).max; if (_totalAmount0Ref > 0) { - _liquidity0Full = FullMath.mulDiv(_amount0Max, _weight0, _totalAmount0Ref); + _liquidity0Full = FullMath.mulDiv(_amount0Max, uint256(_weight0) * _liquidityReference, _totalAmount0Ref); } if (_totalAmount1Ref > 0) { - uint256 _liquidity0ByAmount1 = FullMath.mulDiv(_amount1Max, _weight0, _totalAmount1Ref); + uint256 _liquidity0ByAmount1 = + FullMath.mulDiv(_amount1Max, uint256(_weight0) * _liquidityReference, _totalAmount1Ref); if (_liquidity0ByAmount1 < _liquidity0Full) _liquidity0Full = _liquidity0ByAmount1; } if (_liquidity0Full == 0) revert LPVault__ZeroLiquidity(); diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 3b94650..1e613e2 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -32,6 +32,7 @@ contract LPVaultTest is Test, Deployers { uint256 constant FEE_INDEX_PRECISION = 1e18; uint256 constant FEE_TOLERANCE = 0.01e18; // 1% int24 constant DEFAULT_TICK_SPACING = 60; + uint256 constant INIT_AMOUNT_MAX = 1e24; string constant DEFAULT_NAME = "LP Vault Share"; string constant DEFAULT_SYMBOL = "LPVS"; @@ -91,8 +92,8 @@ contract LPVaultTest is Test, Deployers { /// @dev Assumes address is safe for use as depositor or receiver in tests. function _assumeSafeAddress(address _addr) internal view { vm.assume( - _addr != address(0) && _addr != address(this) && _addr != address(manager) - && _addr != address(vault) + _addr != address(0) && _addr != address(manager) && _addr != address(vault) + && _addr != address(this) ); } @@ -114,6 +115,29 @@ contract LPVaultTest is Test, Deployers { MockERC20(Currency.unwrap(currency1)).approve(address(_to), _amount); } + function _initializeLiquidity(address _initializer) + internal + returns (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) + { + uint256 _amount0Max = INIT_AMOUNT_MAX; + uint256 _amount1Max = INIT_AMOUNT_MAX; + + _mintToken0(_initializer, _amount0Max); + _mintToken1(_initializer, _amount1Max); + _approveToken0(_initializer, address(vault), _amount0Max); + _approveToken1(_initializer, address(vault), _amount1Max); + + vm.prank(_initializer); + (_shares, _amount0Used, _amount1Used) = + vault.initializeLiquidity(_amount0Max, _amount1Max); + } + + function _ensureInitialized(address _initializer) internal { + if (!vault.liquidityInitialized()) { + _initializeLiquidity(_initializer); + } + } + function _deposit(address _depositor, uint256 _amount0, uint256 _amount1) internal returns (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) @@ -126,8 +150,17 @@ contract LPVaultTest is Test, Deployers { _approveToken0(_depositor, address(vault), _amount0Max); _approveToken1(_depositor, address(vault), _amount1Max); + // If not initialized, initialize first with a separate address + if (!vault.liquidityInitialized()) { + address _initializer = makeAddr("initializer"); + _initializeLiquidity(_initializer); + } + + // Use a small liquidity amount that works with bounded token amounts (min 1e6) + uint128 _liquidity = 1e5; + vm.prank(_depositor); - (_shares, _amount0Used, _amount1Used) = vault.deposit(_amount0Max, _amount1Max); + (_shares, _amount0Used, _amount1Used) = vault.deposit(_amount0Max, _amount1Max, _liquidity); } function _calculateExpectedWithdrawAmounts(uint256 _shares) @@ -137,14 +170,14 @@ contract LPVaultTest is Test, Deployers { { PoolId _poolId = defaultPoolKey.toId(); (uint160 _sqrtPriceX96,,,) = manager.getSlot0(_poolId); - uint256 _totalSupply = vault.totalSupply(); + (,,, uint128 _position0Liquidity) = vault.positions(0); for (uint256 _i = 0; _i < defaultPositions.length; _i++) { (int24 _tickLower, int24 _tickUpper,, uint128 _positionLiquidity) = vault.positions(_i); uint128 _liquidityToRemove; if (_i == 0) _liquidityToRemove = uint128(_shares); - else _liquidityToRemove = uint128((_shares * _positionLiquidity) / _totalSupply); + else _liquidityToRemove = uint128(FullMath.mulDiv(_shares, _positionLiquidity, _position0Liquidity)); uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_tickLower); uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_tickUpper); @@ -184,6 +217,111 @@ contract LPVaultTest is Test, Deployers { function _performSwap(bool _zeroForOne, int256 _amountSpecified) internal returns (BalanceDelta) { return swap(defaultPoolKey, _zeroForOne, _amountSpecified, ""); } + + function _getAmountsForLiquidity( + LPVault.LiquidityPosition memory _position, + uint160 _sqrtPriceX96, + uint128 _liquidity + ) internal pure returns (uint256 _amount0, uint256 _amount1) { + if (_liquidity == 0) return (0, 0); + + uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_position.tickLower); + uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_position.tickUpper); + + if (_sqrtPriceX96 <= _sqrtPriceLowerX96) { + _amount0 = + SqrtPriceMath.getAmount0Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); + } else if (_sqrtPriceX96 >= _sqrtPriceUpperX96) { + _amount1 = + SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); + } else { + _amount0 = SqrtPriceMath.getAmount0Delta(_sqrtPriceX96, _sqrtPriceUpperX96, _liquidity, true); + _amount1 = SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceX96, _liquidity, true); + } + } + + /// @dev Calculates expected deposit outputs (shares = first position's liquidity). + function _calculateExpectedDeposit(uint256 _amount0, uint256 _amount1) + internal + view + returns (uint128 _expectedShares, uint256 _expectedAmount0, uint256 _expectedAmount1) + { + PoolId _poolId = defaultPoolKey.toId(); + (uint160 _sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, _poolId); + + uint256 _totalAmount0Ref; + uint256 _totalAmount1Ref; + uint256 _liquidityReference = 1e18; + uint16 _weight0 = defaultPositions[0].weight; + + for (uint256 _i = 0; _i < defaultPositions.length; _i++) { + LPVault.LiquidityPosition memory _position = defaultPositions[_i]; + (uint256 _amount0Ref, uint256 _amount1Ref) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_liquidityReference)); + _totalAmount0Ref += _amount0Ref * _position.weight; + _totalAmount1Ref += _amount1Ref * _position.weight; + } + + uint256 _liquidity0 = type(uint256).max; + if (_totalAmount0Ref > 0) { + _liquidity0 = FullMath.mulDiv(_amount0, uint256(_weight0) * _liquidityReference, _totalAmount0Ref); + } + + uint256 _liquidity0ByAmount1 = type(uint256).max; + if (_totalAmount1Ref > 0) { + _liquidity0ByAmount1 = + FullMath.mulDiv(_amount1, uint256(_weight0) * _liquidityReference, _totalAmount1Ref); + } + + if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; + + _expectedShares = uint128(_liquidity0); + + for (uint256 _i = 0; _i < defaultPositions.length; _i++) { + LPVault.LiquidityPosition memory _position = defaultPositions[_i]; + uint128 _liquidity = _i == 0 + ? _expectedShares + : uint128(FullMath.mulDiv(_liquidity0, _position.weight, _weight0)); + + (uint256 _amount0delta, uint256 _amount1delta) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidity); + + _expectedAmount0 += _amount0delta; + _expectedAmount1 += _amount1delta; + } + } + + function _calculateExpectedDepositAmountsForLiquidity(uint128 _liquidity) + internal + view + returns (uint256 _expectedAmount0, uint256 _expectedAmount1) + { + PoolId _poolId = defaultPoolKey.toId(); + (uint160 _sqrtPriceX96,,,) = manager.getSlot0(_poolId); + (,,, uint128 _position0Liquidity) = vault.positions(0); + + for (uint256 _i = 0; _i < defaultPositions.length; _i++) { + (int24 _tickLower, int24 _tickUpper, uint16 _weight, uint128 _positionLiquidity) = + vault.positions(_i); + + uint128 _liquidityForPosition = _i == 0 + ? _liquidity + : uint128(FullMath.mulDiv(_liquidity, _positionLiquidity, _position0Liquidity)); + + LPVault.LiquidityPosition memory _position = LPVault.LiquidityPosition({ + tickLower: _tickLower, + tickUpper: _tickUpper, + weight: _weight, + liquidity: 0 + }); + + (uint256 _amount0delta, uint256 _amount1delta) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidityForPosition); + + _expectedAmount0 += _amount0delta; + _expectedAmount1 += _amount1delta; + } + } } contract Constructor is LPVaultTest { @@ -332,107 +470,154 @@ contract Constructor is LPVaultTest { } } -contract Deposit is LPVaultTest { - address depositor = makeAddr("depositor"); +contract InitializeLiquidity is LPVaultTest { + address initializer = makeAddr("initializer"); - /// @dev Sets up approvals for a depositor to interact with the vault. - function _approveTokensForVault(address _depositor, uint256 _amount0, uint256 _amount1) internal { - vm.startPrank(_depositor); - MockERC20(Currency.unwrap(currency0)).approve(address(vault), _amount0); - MockERC20(Currency.unwrap(currency1)).approve(address(vault), _amount1); + function _setupInitializer(address _addr) internal { + deal(Currency.unwrap(currency0), _addr, type(uint128).max); + deal(Currency.unwrap(currency1), _addr, type(uint128).max); + vm.startPrank(_addr); + MockERC20(Currency.unwrap(currency0)).approve(address(vault), type(uint128).max); + MockERC20(Currency.unwrap(currency1)).approve(address(vault), type(uint128).max); vm.stopPrank(); } - function _getAmountsForLiquidity( - LPVault.LiquidityPosition memory _position, - uint160 _sqrtPriceX96, - uint128 _liquidity - ) internal pure returns (uint256 _amount0, uint256 _amount1) { - if (_liquidity == 0) return (0, 0); + function test_InitializesLiquidityAndMintsShares() public { + _setupInitializer(initializer); - uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_position.tickLower); - uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_position.tickUpper); + uint256 _token0Before = MockERC20(Currency.unwrap(currency0)).balanceOf(initializer); + uint256 _token1Before = MockERC20(Currency.unwrap(currency1)).balanceOf(initializer); + (uint128 _expectedShares, uint256 _expectedAmount0, uint256 _expectedAmount1) = + _calculateExpectedDeposit(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); - if (_sqrtPriceX96 <= _sqrtPriceLowerX96) { - _amount0 = - SqrtPriceMath.getAmount0Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); - } else if (_sqrtPriceX96 >= _sqrtPriceUpperX96) { - _amount1 = - SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); - } else { - _amount0 = SqrtPriceMath.getAmount0Delta(_sqrtPriceX96, _sqrtPriceUpperX96, _liquidity, true); - _amount1 = SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceX96, _liquidity, true); - } - } + vm.prank(initializer); + (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = + vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); - /// @dev Calculates expected deposit outputs (shares = first position's liquidity). - function _calculateExpectedDeposit(uint256 _amount0, uint256 _amount1) - internal - view - returns (uint128 _expectedShares, uint256 _expectedAmount0, uint256 _expectedAmount1) - { - PoolId _poolId = defaultPoolKey.toId(); - (uint160 _sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, _poolId); + // Shares equal position0 liquidity + (,, uint16 _weight0, uint128 _position0Liquidity) = vault.positions(0); + assertEq(_shares, _position0Liquidity); + assertEq(_shares, _expectedShares); + assertEq(vault.balanceOf(initializer), _shares); + assertEq(vault.totalSupply(), _shares); - uint256 _totalAmount0Ref; - uint256 _totalAmount1Ref; - uint16 _weight0 = defaultPositions[0].weight; + // Tokens were transferred + assertEq(_amount0Used, _expectedAmount0); + assertEq(_amount1Used, _expectedAmount1); + assertEq( + MockERC20(Currency.unwrap(currency0)).balanceOf(initializer), _token0Before - _amount0Used + ); + assertEq( + MockERC20(Currency.unwrap(currency1)).balanceOf(initializer), _token1Before - _amount1Used + ); + // Position liquidity matches weights for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - LPVault.LiquidityPosition memory _position = defaultPositions[_i]; - (uint256 _amount0Ref, uint256 _amount1Ref) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_position.weight)); - _totalAmount0Ref += _amount0Ref; - _totalAmount1Ref += _amount1Ref; + (,, uint16 _weight, uint128 _positionLiquidity) = vault.positions(_i); + uint128 _expectedLiquidity = _i == 0 + ? _position0Liquidity + : uint128(FullMath.mulDiv(_position0Liquidity, _weight, _weight0)); + assertEq(_positionLiquidity, _expectedLiquidity); } - uint256 _liquidity0 = type(uint256).max; - if (_totalAmount0Ref > 0) { - _liquidity0 = FullMath.mulDiv(_amount0, _weight0, _totalAmount0Ref); - } + // State is initialized + assertTrue(vault.liquidityInitialized()); + } - uint256 _liquidity0ByAmount1 = type(uint256).max; - if (_totalAmount1Ref > 0) { - _liquidity0ByAmount1 = FullMath.mulDiv(_amount1, _weight0, _totalAmount1Ref); - } + function test_EmitsDepositedEvent() public { + _setupInitializer(initializer); - if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; + (uint128 _expectedShares, uint256 _expectedAmount0, uint256 _expectedAmount1) = + _calculateExpectedDeposit(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); - _expectedShares = uint128(_liquidity0); + vm.expectEmit(); + emit LPVault.Deposited( + initializer, uint256(_expectedShares), _expectedAmount0, _expectedAmount1 + ); - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - LPVault.LiquidityPosition memory _position = defaultPositions[_i]; - uint128 _liquidity = _i == 0 - ? _expectedShares - : uint128(FullMath.mulDiv(_liquidity0, _position.weight, _weight0)); + vm.prank(initializer); + vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + } - (uint256 _amount0delta, uint256 _amount1delta) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidity); + function testFuzz_CallableByAnyone(address _caller) public { + _assumeSafeAddress(_caller); + _setupInitializer(_caller); - _expectedAmount0 += _amount0delta; - _expectedAmount1 += _amount1delta; - } + vm.prank(_caller); + (uint256 _shares,,) = vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + + (,,, uint128 _position0Liquidity) = vault.positions(0); + assertEq(_shares, _position0Liquidity); + assertEq(vault.balanceOf(_caller), _shares); + assertTrue(vault.liquidityInitialized()); } - function testFuzz_MintsSharesAndTransfersTokens(uint256 _amount0, uint256 _amount1) public { - _amount0 = bound(_amount0, 1e12, type(uint96).max); - _amount1 = bound(_amount1, 1e12, type(uint96).max); - deal(Currency.unwrap(currency0), depositor, _amount0); - deal(Currency.unwrap(currency1), depositor, _amount1); + function test_RevertIf_AlreadyInitialized() public { + _setupInitializer(initializer); - (uint128 _expectedShares, uint256 _expectedAmount0, uint256 _expectedAmount1) = - _calculateExpectedDeposit(_amount0, _amount1); + // First initialization succeeds + vm.prank(initializer); + vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); - _approveTokensForVault(depositor, _amount0, _amount1); + // Second initialization reverts + vm.expectRevert(LPVault.LPVault__AlreadyInitialized.selector); + vm.prank(initializer); + vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + } + + function test_RevertIf_ZeroLiquidityWhenMaxAmountsZero() public { + _setupInitializer(initializer); + + vm.expectRevert(LPVault.LPVault__ZeroLiquidity.selector); + vm.prank(initializer); + vault.initializeLiquidity(0, 0); + } +} + +contract Deposit is LPVaultTest { + address depositor = makeAddr("depositor"); + address initializer = makeAddr("initializer"); + + function setUp() public virtual override { + super.setUp(); + + deal(Currency.unwrap(currency0), initializer, type(uint128).max); + deal(Currency.unwrap(currency1), initializer, type(uint128).max); + vm.startPrank(initializer); + MockERC20(Currency.unwrap(currency0)).approve(address(vault), type(uint128).max); + MockERC20(Currency.unwrap(currency1)).approve(address(vault), type(uint128).max); + vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + vm.stopPrank(); + } + + /// @dev Sets up approvals for a depositor to interact with the vault. + function _approveTokensForVault(address _depositor, uint256 _amount0, uint256 _amount1) internal { + vm.startPrank(_depositor); + MockERC20(Currency.unwrap(currency0)).approve(address(vault), _amount0); + MockERC20(Currency.unwrap(currency1)).approve(address(vault), _amount1); + vm.stopPrank(); + } + + function testFuzz_MintsSharesAndTransfersTokens(uint128 _liquidity) public { + _liquidity = uint128(bound(_liquidity, 1e12, 1e20)); + + uint256 _amount0Max = type(uint128).max; + uint256 _amount1Max = type(uint128).max; + deal(Currency.unwrap(currency0), depositor, _amount0Max); + deal(Currency.unwrap(currency1), depositor, _amount1Max); + + _approveTokensForVault(depositor, _amount0Max, _amount1Max); uint256 _token0BalanceBefore = MockERC20(Currency.unwrap(currency0)).balanceOf(depositor); uint256 _token1BalanceBefore = MockERC20(Currency.unwrap(currency1)).balanceOf(depositor); + (uint256 _expectedAmount0, uint256 _expectedAmount1) = + _calculateExpectedDepositAmountsForLiquidity(_liquidity); vm.prank(depositor); (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = - vault.deposit(_amount0, _amount1); + vault.deposit(_amount0Max, _amount1Max, _liquidity); - // Verify shares were minted. - assertEq(_shares, _expectedShares); + // Verify shares equal liquidity parameter. + assertEq(_shares, _liquidity); assertEq(vault.balanceOf(depositor), _shares); // Verify tokens were transferred. @@ -450,48 +635,28 @@ contract Deposit is LPVaultTest { ); } - function testFuzz_DistributesLiquidityAcrossPositions(uint256 _amount0, uint256 _amount1) public { - _amount0 = bound(_amount0, 1e12, type(uint96).max); - _amount1 = bound(_amount1, 1e12, type(uint96).max); - deal(Currency.unwrap(currency0), depositor, _amount0); - deal(Currency.unwrap(currency1), depositor, _amount1); - _approveTokensForVault(depositor, _amount0, _amount1); - - PoolId _poolId = defaultPoolKey.toId(); - (uint160 _sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, _poolId); + function testFuzz_DistributesLiquidityAcrossPositions(uint128 _liquidity) public { + _liquidity = uint128(bound(_liquidity, 1e12, 1e20)); - // Calculate expected liquidity for each position. - uint128[] memory _expectedLiquidity = new uint128[](defaultPositions.length); - uint16 _weight0 = defaultPositions[0].weight; - uint256 _totalAmount0Ref; - uint256 _totalAmount1Ref; - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - LPVault.LiquidityPosition memory _position = defaultPositions[_i]; - (uint256 _amount0Ref, uint256 _amount1Ref) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_position.weight)); - _totalAmount0Ref += _amount0Ref; - _totalAmount1Ref += _amount1Ref; - } + uint256 _amount0Max = type(uint128).max; + uint256 _amount1Max = type(uint128).max; + deal(Currency.unwrap(currency0), depositor, _amount0Max); + deal(Currency.unwrap(currency1), depositor, _amount1Max); + _approveTokensForVault(depositor, _amount0Max, _amount1Max); - uint256 _liquidity0 = type(uint256).max; - if (_totalAmount0Ref > 0) { - _liquidity0 = FullMath.mulDiv(_amount0, _weight0, _totalAmount0Ref); - } - uint256 _liquidity0ByAmount1 = type(uint256).max; - if (_totalAmount1Ref > 0) { - _liquidity0ByAmount1 = FullMath.mulDiv(_amount1, _weight0, _totalAmount1Ref); - } - if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; + // Get current position liquidity ratios (set by initialization) + (,,, uint128 _initLiquidity0) = vault.positions(0); + (,,, uint128 _initLiquidity1) = vault.positions(1); + (,,, uint128 _initLiquidity2) = vault.positions(2); - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - LPVault.LiquidityPosition memory _position = defaultPositions[_i]; - _expectedLiquidity[_i] = _i == 0 - ? uint128(_liquidity0) - : uint128(FullMath.mulDiv(_liquidity0, _position.weight, _weight0)); - } + // Calculate expected liquidity for each position based on current ratios + uint128[] memory _expectedLiquidity = new uint128[](defaultPositions.length); + _expectedLiquidity[0] = _initLiquidity0 + _liquidity; + _expectedLiquidity[1] = _initLiquidity1 + uint128(FullMath.mulDiv(_liquidity, _initLiquidity1, _initLiquidity0)); + _expectedLiquidity[2] = _initLiquidity2 + uint128(FullMath.mulDiv(_liquidity, _initLiquidity2, _initLiquidity0)); vm.prank(depositor); - vault.deposit(_amount0, _amount1); + vault.deposit(_amount0Max, _amount1Max, _liquidity); // Verify each position's liquidity was updated correctly. for (uint256 _i = 0; _i < defaultPositions.length; _i++) { @@ -500,49 +665,74 @@ contract Deposit is LPVaultTest { } } - function testFuzz_DepositEmitsDepositedEvent(uint256 _amount0, uint256 _amount1) public { - _amount0 = bound(_amount0, 1e12, type(uint96).max); - _amount1 = bound(_amount1, 1e12, type(uint96).max); - deal(Currency.unwrap(currency0), depositor, _amount0); - deal(Currency.unwrap(currency1), depositor, _amount1); - _approveTokensForVault(depositor, _amount0, _amount1); + function testFuzz_DepositEmitsDepositedEvent(uint128 _liquidity) public { + _liquidity = uint128(bound(_liquidity, 1e12, 1e20)); - (uint128 _expectedShares, uint256 _expectedAmount0, uint256 _expectedAmount1) = - _calculateExpectedDeposit(_amount0, _amount1); + uint256 _amount0Max = type(uint128).max; + uint256 _amount1Max = type(uint128).max; + deal(Currency.unwrap(currency0), depositor, _amount0Max); + deal(Currency.unwrap(currency1), depositor, _amount1Max); + _approveTokensForVault(depositor, _amount0Max, _amount1Max); + + (uint256 _expectedAmount0, uint256 _expectedAmount1) = + _calculateExpectedDepositAmountsForLiquidity(_liquidity); vm.prank(depositor); vm.expectEmit(); - emit LPVault.Deposited(depositor, _expectedShares, _expectedAmount0, _expectedAmount1); - vault.deposit(_amount0, _amount1); + emit LPVault.Deposited(depositor, uint256(_liquidity), _expectedAmount0, _expectedAmount1); + vault.deposit(_amount0Max, _amount1Max, _liquidity); } - function testFuzz_MultipleDeposits(uint256 _amount0, uint256 _amount1) public { - _amount0 = bound(_amount0, 1e12, type(uint96).max / 2); - _amount1 = bound(_amount1, 1e12, type(uint96).max / 2); - deal(Currency.unwrap(currency0), depositor, _amount0 * 2); - deal(Currency.unwrap(currency1), depositor, _amount1 * 2); - _approveTokensForVault(depositor, _amount0 * 2, _amount1 * 2); + function testFuzz_MultipleDeposits(uint128 _liquidity) public { + _liquidity = uint128(bound(_liquidity, 1e12, 1e18)); + + uint256 _totalSupplyBefore = vault.totalSupply(); + + uint256 _amount0Max = type(uint128).max; + uint256 _amount1Max = type(uint128).max; + deal(Currency.unwrap(currency0), depositor, _amount0Max); + deal(Currency.unwrap(currency1), depositor, _amount1Max); + _approveTokensForVault(depositor, _amount0Max, _amount1Max); // First deposit. vm.prank(depositor); - (uint256 _shares1,,) = vault.deposit(_amount0, _amount1); + (uint256 _shares1,,) = vault.deposit(_amount0Max, _amount1Max, _liquidity); // Second deposit. vm.prank(depositor); - (uint256 _shares2,,) = vault.deposit(_amount0, _amount1); + (uint256 _shares2,,) = vault.deposit(_amount0Max, _amount1Max, _liquidity); - // Verify equal deposits yield equal shares (share parity). + // Verify equal liquidity yields equal shares. assertEq(_shares1, _shares2); + assertEq(_shares1, _liquidity); // Verify total shares. assertEq(vault.balanceOf(depositor), _shares1 + _shares2); - assertEq(vault.totalSupply(), _shares1 + _shares2); + // Total supply includes initializer shares + depositor shares + assertEq(vault.totalSupply(), _totalSupplyBefore + _shares1 + _shares2); } function test_RevertIf_ZeroLiquidity() public { vm.prank(depositor); vm.expectRevert(LPVault.LPVault__ZeroLiquidity.selector); - vault.deposit(0, 0); + vault.deposit(1e18, 1e18, 0); + } + + function test_RevertIf_NotInitialized(address _caller, uint128 _liquidity) public { + // Deploy a fresh vault without initialization + LPVault freshVault = new LPVault(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + + _assumeSafeAddress(_caller); + _liquidity = uint128(bound(_liquidity, 1, type(uint96).max)); + deal(Currency.unwrap(currency0), _caller, 1e18); + deal(Currency.unwrap(currency1), _caller, 1e18); + vm.startPrank(_caller); + MockERC20(Currency.unwrap(currency0)).approve(address(freshVault), 1e18); + MockERC20(Currency.unwrap(currency1)).approve(address(freshVault), 1e18); + + vm.expectRevert(LPVault.LPVault__NotInitialized.selector); + freshVault.deposit(1e18, 1e18, _liquidity); + vm.stopPrank(); } function testFuzz_RevertIf_UnlockCallbackCalledByNonPoolManager( @@ -552,8 +742,16 @@ contract Deposit is LPVaultTest { ) public { vm.assume(_caller != address(manager)); + uint128[] memory _liquidityPerPosition = new uint128[](3); + _liquidityPerPosition[0] = 1e18; + _liquidityPerPosition[1] = 5e17; + _liquidityPerPosition[2] = 5e17; + LPVault.DepositCallbackData memory _callbackData = LPVault.DepositCallbackData({ - sender: address(this), amount0Max: _amount0Max, amount1Max: _amount1Max + sender: address(this), + amount0Max: _amount0Max, + amount1Max: _amount1Max, + liquidityPerPosition: _liquidityPerPosition }); vm.prank(_caller); @@ -604,19 +802,26 @@ contract Withdraw is LPVaultTest { _assumeSafeAddress(_receiver); vm.assume(_depositor != _receiver); + // Ensure initialization happens before capturing state + _ensureInitialized(makeAddr("initForTest")); + + uint256 _totalSupplyBefore = vault.totalSupply(); + (,,, uint128 _position0LiquidityBefore) = vault.positions(0); + (uint256 _shares, uint256 _amount0Deposited, uint256 _amount1Deposited) = _deposit(_depositor, _amount0, _amount1); uint256 _receiverToken0Before = MockERC20(Currency.unwrap(currency0)).balanceOf(_receiver); uint256 _receiverToken1Before = MockERC20(Currency.unwrap(currency1)).balanceOf(_receiver); - // Withdraw all shares. + // Withdraw all depositor's shares. vm.prank(_depositor); (uint256 _amount0Received, uint256 _amount1Received) = vault.withdraw(_shares, _receiver); - // Verify shares were burned. + // Verify depositor's shares were burned. assertEq(vault.balanceOf(_depositor), 0); - assertEq(vault.totalSupply(), 0); + // Total supply returns to pre-deposit state (initializer shares remain) + assertEq(vault.totalSupply(), _totalSupplyBefore); // Verify receiver got tokens. assertEq( @@ -633,9 +838,9 @@ contract Withdraw is LPVaultTest { assertApproxEqAbs(_amount0Received, _amount0Deposited, 2); assertApproxEqAbs(_amount1Received, _amount1Deposited, 2); - // Verify position liquidity is zero after full withdrawal. - (,,, uint128 _positionLiquidity) = vault.positions(0); - assertEq(_positionLiquidity, 0); + // Verify position liquidity returns to pre-deposit state. + (,,, uint128 _position0LiquidityAfter) = vault.positions(0); + assertEq(_position0LiquidityAfter, _position0LiquidityBefore); } function testFuzz_WithdrawEmitsEvent( @@ -669,6 +874,11 @@ contract Withdraw is LPVaultTest { _assumeSafeAddress(_receiver); _withdrawPercent = bound(_withdrawPercent, 1, 99); + // Ensure initialization happens before capturing state + _ensureInitialized(makeAddr("initForTest")); + + uint256 _totalSupplyBefore = vault.totalSupply(); + (uint256 _shares,,) = _deposit(_depositor, _amount0, _amount1); uint256 _sharesToWithdraw = (_shares * _withdrawPercent) / 100; @@ -682,7 +892,8 @@ contract Withdraw is LPVaultTest { vault.withdraw(_sharesToWithdraw, _receiver); assertEq(vault.balanceOf(_depositor), _expectedRemainingShares); - assertEq(vault.totalSupply(), _expectedRemainingShares); + // Total supply = initializer shares + depositor remaining shares + assertEq(vault.totalSupply(), _totalSupplyBefore + _expectedRemainingShares); assertEq( MockERC20(Currency.unwrap(currency0)).balanceOf(_receiver), _receiverToken0Before + _amount0Received @@ -702,6 +913,12 @@ contract Withdraw is LPVaultTest { _assumeSafeAddress(_depositor); vm.assume(_receiver != address(0)); + // Ensure initialization happens before capturing state + _ensureInitialized(makeAddr("initForTest")); + + uint256 _totalSupplyBefore = vault.totalSupply(); + (,,, uint128 _position0LiquidityBefore) = vault.positions(0); + // First deposit. (uint256 _shares1, uint256 _amount0Used1, uint256 _amount1Used1) = _deposit(_depositor, _amount0, _amount1); @@ -715,23 +932,22 @@ contract Withdraw is LPVaultTest { _amount1Used1 += _amount1Used2; } - // Withdraw all. + // Withdraw all depositor's shares. vm.prank(_depositor); (uint256 _amount0Received, uint256 _amount1Received) = vault.withdraw(_shares1, _receiver); - // Verify all shares burned. + // Verify depositor's shares burned. assertEq(vault.balanceOf(_depositor), 0); - assertEq(vault.totalSupply(), 0); + // Total supply returns to initializer shares + assertEq(vault.totalSupply(), _totalSupplyBefore); // Verify amounts received match total deposited (within rounding tolerance). assertApproxEqAbs(_amount0Received, _amount0Used1, 5); assertApproxEqAbs(_amount1Received, _amount1Used1, 5); - // Verify liquidity was proportionally removed (all positions should be empty). - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - (,,, uint128 _positionLiquidity) = vault.positions(_i); - assertEq(_positionLiquidity, 0); - } + // Verify liquidity returns to pre-deposit state. + (,,, uint128 _position0LiquidityAfter) = vault.positions(0); + assertEq(_position0LiquidityAfter, _position0LiquidityBefore); } function testFuzz_MultipleDepositsAndMultipleWithdraws( @@ -742,80 +958,38 @@ contract Withdraw is LPVaultTest { ) public { _assumeSafeAddress(_depositor); vm.assume(_receiver != address(0)); + vm.assume(_depositor != makeAddr("initForTest")); - uint256 _totalShares; - uint256 _totalAmount0Used; - uint256 _totalAmount1Used; + // Ensure initialization happens before capturing state + _ensureInitialized(makeAddr("initForTest")); - // First deposit. - { - (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = - _deposit(_depositor, _amount0, _amount1); - _totalShares = _shares; - _totalAmount0Used = _amount0Used; - _totalAmount1Used = _amount1Used; - } + uint256 _initSupply = vault.totalSupply(); - // Second deposit. - { - (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = - _deposit(_depositor, _amount0, _amount1); - _totalShares += _shares; - _totalAmount0Used += _amount0Used; - _totalAmount1Used += _amount1Used; - } + // Two deposits. + (uint256 _shares1,,) = _deposit(_depositor, _amount0, _amount1); + (uint256 _shares2,,) = _deposit(_depositor, _amount0, _amount1); + uint256 _totalShares = _shares1 + _shares2; - // Verify total shares minted. + // Verify total shares minted for depositor. assertEq(vault.balanceOf(_depositor), _totalShares); - assertEq(vault.totalSupply(), _totalShares); - - uint256 _totalAmount0Received; - uint256 _totalAmount1Received; + assertEq(vault.totalSupply(), _initSupply + _totalShares); - // First withdrawal - withdraw half of shares. - { - uint256 _sharesToWithdraw = _totalShares / 2; - vm.prank(_depositor); - (uint256 _amount0Received, uint256 _amount1Received) = - vault.withdraw(_sharesToWithdraw, _receiver); - _totalAmount0Received = _amount0Received; - _totalAmount1Received = _amount1Received; - - // Verify shares after first withdrawal. - assertEq(vault.balanceOf(_depositor), _totalShares - _sharesToWithdraw); - assertEq(vault.totalSupply(), _totalShares - _sharesToWithdraw); - - // Verify liquidity was proportionally removed after first withdrawal. - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - (,,, uint128 _positionLiquidity) = vault.positions(_i); - assertGt(_positionLiquidity, 0); - } - } + // First withdrawal - withdraw half. + uint256 _half = _totalShares / 2; + vm.prank(_depositor); + vault.withdraw(_half, _receiver); + assertEq(vault.balanceOf(_depositor), _totalShares - _half); - // Second withdrawal - withdraw remaining shares. - { - uint256 _remainingShares = vault.balanceOf(_depositor); + // Second withdrawal - withdraw remaining. + uint256 _remaining = vault.balanceOf(_depositor); + if (_remaining > 0) { vm.prank(_depositor); - (uint256 _amount0Received, uint256 _amount1Received) = - vault.withdraw(_remainingShares, _receiver); - _totalAmount0Received += _amount0Received; - _totalAmount1Received += _amount1Received; + vault.withdraw(_remaining, _receiver); } - // Verify all shares burned. + // Verify depositor shares burned. assertEq(vault.balanceOf(_depositor), 0); - assertEq(vault.totalSupply(), 0); - - // Verify total amounts received match total deposited (within rounding tolerance). - // Tolerance is higher due to accumulated rounding across multiple deposits and withdrawals. - assertApproxEqAbs(_totalAmount0Received, _totalAmount0Used, 6); - assertApproxEqAbs(_totalAmount1Received, _totalAmount1Used, 6); - - // Verify liquidity was fully removed (all positions should be empty). - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - (,,, uint128 _positionLiquidity) = vault.positions(_i); - assertEq(_positionLiquidity, 0); - } + assertEq(vault.totalSupply(), _initSupply); } function testFuzz_RevertIf_ZeroShares(address _depositor, address _receiver) public { @@ -1156,13 +1330,15 @@ contract CollectFees is LPVaultTest { function testFuzz_ClaimFeesAfterCollect( address _depositor, - uint256 _amount0, - uint256 _amount1, + uint256, + uint256, uint256 _swapAbs0, uint256 _swapAbs1 ) public { _assumeSafeAddress(_depositor); - _deposit(_depositor, _amount0, _amount1); + + // Initialize with the depositor so they hold all shares and receive all fees + _initializeLiquidity(_depositor); _performSwap(true, _boundSwapAbs(_swapAbs0)); _performSwap(false, _boundSwapAbs(_swapAbs1)); From 48ef03f958fd46d3a4e6f2d8c0b51766d5650951 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Tue, 27 Jan 2026 11:04:49 -0500 Subject: [PATCH 07/16] Run `scopelint fmt` --- src/LPVault.sol | 33 +++++++++++++++++++-------------- test/LPVault.t.sol | 32 +++++++++++++++++--------------- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index bd6e20e..067e63f 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -202,8 +202,8 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { if (_totalWeight != BIPS) revert LPVault__InvalidLiquidityDistribution(); } - /// @notice Initializes liquidity for all positions based on configured weights. Can only be called once. - /// @param _amount0Max Maximum amount of token0 willing to provide. + /// @notice Initializes liquidity for all positions based on configured weights. Can only be + /// called once. @param _amount0Max Maximum amount of token0 willing to provide. /// @param _amount1Max Maximum amount of token1 willing to provide. /// @return _shares The amount of vault shares minted. /// @return _amount0Used The actual amount of token0 used. @@ -254,8 +254,8 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { /// @notice Deposits tokens into the vault by specifying desired position0 liquidity. /// @param _amount0Max The maximum amount of token0 to deposit across all positions. /// @param _amount1Max The maximum amount of token1 to deposit across all positions. - /// @param _liquidity The liquidity to add to position0. Other positions calculated proportionally. - /// @dev Requires liquidity to be initialized first via initializeLiquidity(). + /// @param _liquidity The liquidity to add to position0. Other positions calculated + /// proportionally. @dev Requires liquidity to be initialized first via initializeLiquidity(). /// @return _shares The amount of vault shares minted (equal to _liquidity). /// @return _amount0Used The actual amount of token0 used. /// @return _amount1Used The actual amount of token1 used. @@ -342,9 +342,7 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { } // If all liquidity is withdrawn, reset initialization to allow re-initialization. - if (positions[0].liquidity == 0) { - liquidityInitialized = false; - } + if (positions[0].liquidity == 0) liquidityInitialized = false; // Burn shares before making external calls (reentrancy protection). _burn(msg.sender, _shares); @@ -385,7 +383,9 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { if (_action == CallbackAction.Deposit) return _handleDepositCallback(_actionData); if (_action == CallbackAction.Withdraw) return _handleWithdrawCallback(_actionData); - if (_action == CallbackAction.InitializeLiquidity) return _handleInitializeLiquidityCallback(_actionData); + if (_action == CallbackAction.InitializeLiquidity) { + return _handleInitializeLiquidityCallback(_actionData); + } return _handleCollectFeesCallback(); } @@ -407,10 +407,9 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { { DepositCallbackData memory _callbackData = abi.decode(_actionData, (DepositCallbackData)); - (int256 _totalDelta0, int256 _totalDelta1, int256 _totalFeeDelta0, int256 _totalFeeDelta1) = - _addLiquidityToPositions( - _callbackData.liquidityPerPosition, _callbackData.amount0Max, _callbackData.amount1Max - ); + (int256 _totalDelta0, int256 _totalDelta1, int256 _totalFeeDelta0, int256 _totalFeeDelta1) = _addLiquidityToPositions( + _callbackData.liquidityPerPosition, _callbackData.amount0Max, _callbackData.amount1Max + ); (uint256 _amount0Used, uint256 _amount1Used) = _settleDeltas(_totalDelta0, _totalDelta1, _callbackData.sender); @@ -540,7 +539,8 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { uint256 _liquidity0Full = type(uint256).max; if (_totalAmount0Ref > 0) { - _liquidity0Full = FullMath.mulDiv(_amount0Max, uint256(_weight0) * _liquidityReference, _totalAmount0Ref); + _liquidity0Full = + FullMath.mulDiv(_amount0Max, uint256(_weight0) * _liquidityReference, _totalAmount0Ref); } if (_totalAmount1Ref > 0) { uint256 _liquidity0ByAmount1 = @@ -573,7 +573,12 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { ) internal virtual - returns (int256 _totalDelta0, int256 _totalDelta1, int256 _totalFeeDelta0, int256 _totalFeeDelta1) + returns ( + int256 _totalDelta0, + int256 _totalDelta1, + int256 _totalFeeDelta0, + int256 _totalFeeDelta1 + ) { for (uint256 _i = 0; _i < positions.length; _i++) { LiquidityPosition memory _position = positions[_i]; diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 1e613e2..2828fdb 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -128,14 +128,11 @@ contract LPVaultTest is Test, Deployers { _approveToken1(_initializer, address(vault), _amount1Max); vm.prank(_initializer); - (_shares, _amount0Used, _amount1Used) = - vault.initializeLiquidity(_amount0Max, _amount1Max); + (_shares, _amount0Used, _amount1Used) = vault.initializeLiquidity(_amount0Max, _amount1Max); } function _ensureInitialized(address _initializer) internal { - if (!vault.liquidityInitialized()) { - _initializeLiquidity(_initializer); - } + if (!vault.liquidityInitialized()) _initializeLiquidity(_initializer); } function _deposit(address _depositor, uint256 _amount0, uint256 _amount1) @@ -176,8 +173,12 @@ contract LPVaultTest is Test, Deployers { (int24 _tickLower, int24 _tickUpper,, uint128 _positionLiquidity) = vault.positions(_i); uint128 _liquidityToRemove; - if (_i == 0) _liquidityToRemove = uint128(_shares); - else _liquidityToRemove = uint128(FullMath.mulDiv(_shares, _positionLiquidity, _position0Liquidity)); + if (_i == 0) { + _liquidityToRemove = uint128(_shares); + } else { + _liquidityToRemove = + uint128(FullMath.mulDiv(_shares, _positionLiquidity, _position0Liquidity)); + } uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_tickLower); uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_tickUpper); @@ -264,7 +265,8 @@ contract LPVaultTest is Test, Deployers { uint256 _liquidity0 = type(uint256).max; if (_totalAmount0Ref > 0) { - _liquidity0 = FullMath.mulDiv(_amount0, uint256(_weight0) * _liquidityReference, _totalAmount0Ref); + _liquidity0 = + FullMath.mulDiv(_amount0, uint256(_weight0) * _liquidityReference, _totalAmount0Ref); } uint256 _liquidity0ByAmount1 = type(uint256).max; @@ -309,10 +311,7 @@ contract LPVaultTest is Test, Deployers { : uint128(FullMath.mulDiv(_liquidity, _positionLiquidity, _position0Liquidity)); LPVault.LiquidityPosition memory _position = LPVault.LiquidityPosition({ - tickLower: _tickLower, - tickUpper: _tickUpper, - weight: _weight, - liquidity: 0 + tickLower: _tickLower, tickUpper: _tickUpper, weight: _weight, liquidity: 0 }); (uint256 _amount0delta, uint256 _amount1delta) = @@ -652,8 +651,10 @@ contract Deposit is LPVaultTest { // Calculate expected liquidity for each position based on current ratios uint128[] memory _expectedLiquidity = new uint128[](defaultPositions.length); _expectedLiquidity[0] = _initLiquidity0 + _liquidity; - _expectedLiquidity[1] = _initLiquidity1 + uint128(FullMath.mulDiv(_liquidity, _initLiquidity1, _initLiquidity0)); - _expectedLiquidity[2] = _initLiquidity2 + uint128(FullMath.mulDiv(_liquidity, _initLiquidity2, _initLiquidity0)); + _expectedLiquidity[1] = + _initLiquidity1 + uint128(FullMath.mulDiv(_liquidity, _initLiquidity1, _initLiquidity0)); + _expectedLiquidity[2] = + _initLiquidity2 + uint128(FullMath.mulDiv(_liquidity, _initLiquidity2, _initLiquidity0)); vm.prank(depositor); vault.deposit(_amount0Max, _amount1Max, _liquidity); @@ -720,7 +721,8 @@ contract Deposit is LPVaultTest { function test_RevertIf_NotInitialized(address _caller, uint128 _liquidity) public { // Deploy a fresh vault without initialization - LPVault freshVault = new LPVault(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + LPVault freshVault = + new LPVault(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); _assumeSafeAddress(_caller); _liquidity = uint128(bound(_liquidity, 1, type(uint96).max)); From 86b50a374d51fa9963d8ded899fd11255395352b Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Fri, 30 Jan 2026 01:57:02 -0500 Subject: [PATCH 08/16] Set up harness and update tests --- src/LPVault.sol | 3 +- test/LPVault.t.sol | 304 ++++++++------------------------ test/harness/LPVaultHarness.sol | 24 +++ 3 files changed, 95 insertions(+), 236 deletions(-) create mode 100644 test/harness/LPVaultHarness.sol diff --git a/src/LPVault.sol b/src/LPVault.sol index 067e63f..03f523b 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -255,7 +255,8 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { /// @param _amount0Max The maximum amount of token0 to deposit across all positions. /// @param _amount1Max The maximum amount of token1 to deposit across all positions. /// @param _liquidity The liquidity to add to position0. Other positions calculated - /// proportionally. @dev Requires liquidity to be initialized first via initializeLiquidity(). + /// proportionally. + /// @dev Requires liquidity to be initialized first via initializeLiquidity(). /// @return _shares The amount of vault shares minted (equal to _liquidity). /// @return _amount0Used The actual amount of token0 used. /// @return _amount1Used The actual amount of token1 used. diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 2828fdb..1b74e90 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -6,23 +6,15 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Deployers} from "lib/v4-periphery/lib/v4-core/test/utils/Deployers.sol"; import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; import {LPVault} from "src/LPVault.sol"; -import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {LPVaultHarness} from "test/harness/LPVaultHarness.sol"; import {ImmutableState} from "v4-periphery/src/base/ImmutableState.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; -import {PoolId} from "v4-core/types/PoolId.sol"; import {Currency} from "v4-core/types/Currency.sol"; import {IHooks} from "v4-core/interfaces/IHooks.sol"; -import {PoolIdLibrary} from "v4-core/types/PoolId.sol"; -import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; -import {TickMath} from "v4-core/libraries/TickMath.sol"; import {FullMath} from "v4-core/libraries/FullMath.sol"; -import {SqrtPriceMath} from "v4-core/libraries/SqrtPriceMath.sol"; import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; contract LPVaultTest is Test, Deployers { - using PoolIdLibrary for PoolKey; - using StateLibrary for IPoolManager; - int24 constant MIN_TICK = -887_272; int24 constant MAX_TICK = 887_272; @@ -160,57 +152,6 @@ contract LPVaultTest is Test, Deployers { (_shares, _amount0Used, _amount1Used) = vault.deposit(_amount0Max, _amount1Max, _liquidity); } - function _calculateExpectedWithdrawAmounts(uint256 _shares) - internal - view - returns (uint256 _expectedAmount0, uint256 _expectedAmount1) - { - PoolId _poolId = defaultPoolKey.toId(); - (uint160 _sqrtPriceX96,,,) = manager.getSlot0(_poolId); - (,,, uint128 _position0Liquidity) = vault.positions(0); - - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - (int24 _tickLower, int24 _tickUpper,, uint128 _positionLiquidity) = vault.positions(_i); - - uint128 _liquidityToRemove; - if (_i == 0) { - _liquidityToRemove = uint128(_shares); - } else { - _liquidityToRemove = - uint128(FullMath.mulDiv(_shares, _positionLiquidity, _position0Liquidity)); - } - - uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_tickLower); - uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_tickUpper); - - uint256 _amount0; - uint256 _amount1; - - if (_sqrtPriceX96 <= _sqrtPriceLowerX96) { - // Current price is below the range, position is entirely in token0. - _amount0 = SqrtPriceMath.getAmount0Delta( - _sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidityToRemove, false - ); - } else if (_sqrtPriceX96 >= _sqrtPriceUpperX96) { - // Current price is above the range, position is entirely in token1. - _amount1 = SqrtPriceMath.getAmount1Delta( - _sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidityToRemove, false - ); - } else { - // Current price is within the range, position has both tokens. - _amount0 = SqrtPriceMath.getAmount0Delta( - _sqrtPriceX96, _sqrtPriceUpperX96, _liquidityToRemove, false - ); - _amount1 = SqrtPriceMath.getAmount1Delta( - _sqrtPriceLowerX96, _sqrtPriceX96, _liquidityToRemove, false - ); - } - - _expectedAmount0 += _amount0; - _expectedAmount1 += _amount1; - } - } - function _boundSwapAbs(uint256 _swapAbs) internal pure returns (int256) { return -int256(bound(_swapAbs, 1e18, 1e24)); } @@ -219,108 +160,6 @@ contract LPVaultTest is Test, Deployers { return swap(defaultPoolKey, _zeroForOne, _amountSpecified, ""); } - function _getAmountsForLiquidity( - LPVault.LiquidityPosition memory _position, - uint160 _sqrtPriceX96, - uint128 _liquidity - ) internal pure returns (uint256 _amount0, uint256 _amount1) { - if (_liquidity == 0) return (0, 0); - - uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_position.tickLower); - uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_position.tickUpper); - - if (_sqrtPriceX96 <= _sqrtPriceLowerX96) { - _amount0 = - SqrtPriceMath.getAmount0Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); - } else if (_sqrtPriceX96 >= _sqrtPriceUpperX96) { - _amount1 = - SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidity, true); - } else { - _amount0 = SqrtPriceMath.getAmount0Delta(_sqrtPriceX96, _sqrtPriceUpperX96, _liquidity, true); - _amount1 = SqrtPriceMath.getAmount1Delta(_sqrtPriceLowerX96, _sqrtPriceX96, _liquidity, true); - } - } - - /// @dev Calculates expected deposit outputs (shares = first position's liquidity). - function _calculateExpectedDeposit(uint256 _amount0, uint256 _amount1) - internal - view - returns (uint128 _expectedShares, uint256 _expectedAmount0, uint256 _expectedAmount1) - { - PoolId _poolId = defaultPoolKey.toId(); - (uint160 _sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, _poolId); - - uint256 _totalAmount0Ref; - uint256 _totalAmount1Ref; - uint256 _liquidityReference = 1e18; - uint16 _weight0 = defaultPositions[0].weight; - - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - LPVault.LiquidityPosition memory _position = defaultPositions[_i]; - (uint256 _amount0Ref, uint256 _amount1Ref) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_liquidityReference)); - _totalAmount0Ref += _amount0Ref * _position.weight; - _totalAmount1Ref += _amount1Ref * _position.weight; - } - - uint256 _liquidity0 = type(uint256).max; - if (_totalAmount0Ref > 0) { - _liquidity0 = - FullMath.mulDiv(_amount0, uint256(_weight0) * _liquidityReference, _totalAmount0Ref); - } - - uint256 _liquidity0ByAmount1 = type(uint256).max; - if (_totalAmount1Ref > 0) { - _liquidity0ByAmount1 = - FullMath.mulDiv(_amount1, uint256(_weight0) * _liquidityReference, _totalAmount1Ref); - } - - if (_liquidity0ByAmount1 < _liquidity0) _liquidity0 = _liquidity0ByAmount1; - - _expectedShares = uint128(_liquidity0); - - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - LPVault.LiquidityPosition memory _position = defaultPositions[_i]; - uint128 _liquidity = _i == 0 - ? _expectedShares - : uint128(FullMath.mulDiv(_liquidity0, _position.weight, _weight0)); - - (uint256 _amount0delta, uint256 _amount1delta) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidity); - - _expectedAmount0 += _amount0delta; - _expectedAmount1 += _amount1delta; - } - } - - function _calculateExpectedDepositAmountsForLiquidity(uint128 _liquidity) - internal - view - returns (uint256 _expectedAmount0, uint256 _expectedAmount1) - { - PoolId _poolId = defaultPoolKey.toId(); - (uint160 _sqrtPriceX96,,,) = manager.getSlot0(_poolId); - (,,, uint128 _position0Liquidity) = vault.positions(0); - - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - (int24 _tickLower, int24 _tickUpper, uint16 _weight, uint128 _positionLiquidity) = - vault.positions(_i); - - uint128 _liquidityForPosition = _i == 0 - ? _liquidity - : uint128(FullMath.mulDiv(_liquidity, _positionLiquidity, _position0Liquidity)); - - LPVault.LiquidityPosition memory _position = LPVault.LiquidityPosition({ - tickLower: _tickLower, tickUpper: _tickUpper, weight: _weight, liquidity: 0 - }); - - (uint256 _amount0delta, uint256 _amount1delta) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidityForPosition); - - _expectedAmount0 += _amount0delta; - _expectedAmount1 += _amount1delta; - } - } } contract Constructor is LPVaultTest { @@ -472,7 +311,7 @@ contract Constructor is LPVaultTest { contract InitializeLiquidity is LPVaultTest { address initializer = makeAddr("initializer"); - function _setupInitializer(address _addr) internal { + function _fundAndApproveInitializer(address _addr) internal { deal(Currency.unwrap(currency0), _addr, type(uint128).max); deal(Currency.unwrap(currency1), _addr, type(uint128).max); vm.startPrank(_addr); @@ -482,41 +321,38 @@ contract InitializeLiquidity is LPVaultTest { } function test_InitializesLiquidityAndMintsShares() public { - _setupInitializer(initializer); + _fundAndApproveInitializer(initializer); uint256 _token0Before = MockERC20(Currency.unwrap(currency0)).balanceOf(initializer); uint256 _token1Before = MockERC20(Currency.unwrap(currency1)).balanceOf(initializer); - (uint128 _expectedShares, uint256 _expectedAmount0, uint256 _expectedAmount1) = - _calculateExpectedDeposit(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + LPVaultHarness _lpVaultHarness = + new LPVaultHarness(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + (uint128[] memory _expectedLiquidityPerPosition, uint128 _expectedLiquidity0) = + _lpVaultHarness.computeInitialLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); vm.prank(initializer); (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); // Shares equal position0 liquidity - (,, uint16 _weight0, uint128 _position0Liquidity) = vault.positions(0); + (,,, uint128 _position0Liquidity) = vault.positions(0); + assertEq(_shares, _expectedLiquidity0); assertEq(_shares, _position0Liquidity); - assertEq(_shares, _expectedShares); assertEq(vault.balanceOf(initializer), _shares); assertEq(vault.totalSupply(), _shares); // Tokens were transferred - assertEq(_amount0Used, _expectedAmount0); - assertEq(_amount1Used, _expectedAmount1); - assertEq( - MockERC20(Currency.unwrap(currency0)).balanceOf(initializer), _token0Before - _amount0Used - ); - assertEq( - MockERC20(Currency.unwrap(currency1)).balanceOf(initializer), _token1Before - _amount1Used - ); - - // Position liquidity matches weights + uint256 _token0After = MockERC20(Currency.unwrap(currency0)).balanceOf(initializer); + uint256 _token1After = MockERC20(Currency.unwrap(currency1)).balanceOf(initializer); + assertEq(_amount0Used, _token0Before - _token0After); + assertEq(_amount1Used, _token1Before - _token1After); + assertEq(_token0After, _token0Before - _amount0Used); + assertEq(_token1After, _token1Before - _amount1Used); + + // Position liquidity matches preview for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - (,, uint16 _weight, uint128 _positionLiquidity) = vault.positions(_i); - uint128 _expectedLiquidity = _i == 0 - ? _position0Liquidity - : uint128(FullMath.mulDiv(_position0Liquidity, _weight, _weight0)); - assertEq(_positionLiquidity, _expectedLiquidity); + (,,, uint128 _positionLiquidity) = vault.positions(_i); + assertEq(_positionLiquidity, _expectedLiquidityPerPosition[_i]); } // State is initialized @@ -524,15 +360,16 @@ contract InitializeLiquidity is LPVaultTest { } function test_EmitsDepositedEvent() public { - _setupInitializer(initializer); + _fundAndApproveInitializer(initializer); - (uint128 _expectedShares, uint256 _expectedAmount0, uint256 _expectedAmount1) = - _calculateExpectedDeposit(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + uint256 _snapshot = vm.snapshot(); + vm.prank(initializer); + (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = + vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + vm.revertTo(_snapshot); vm.expectEmit(); - emit LPVault.Deposited( - initializer, uint256(_expectedShares), _expectedAmount0, _expectedAmount1 - ); + emit LPVault.Deposited(initializer, _shares, _amount0Used, _amount1Used); vm.prank(initializer); vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); @@ -540,7 +377,7 @@ contract InitializeLiquidity is LPVaultTest { function testFuzz_CallableByAnyone(address _caller) public { _assumeSafeAddress(_caller); - _setupInitializer(_caller); + _fundAndApproveInitializer(_caller); vm.prank(_caller); (uint256 _shares,,) = vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); @@ -552,7 +389,7 @@ contract InitializeLiquidity is LPVaultTest { } function test_RevertIf_AlreadyInitialized() public { - _setupInitializer(initializer); + _fundAndApproveInitializer(initializer); // First initialization succeeds vm.prank(initializer); @@ -565,7 +402,7 @@ contract InitializeLiquidity is LPVaultTest { } function test_RevertIf_ZeroLiquidityWhenMaxAmountsZero() public { - _setupInitializer(initializer); + _fundAndApproveInitializer(initializer); vm.expectRevert(LPVault.LPVault__ZeroLiquidity.selector); vm.prank(initializer); @@ -608,8 +445,6 @@ contract Deposit is LPVaultTest { _approveTokensForVault(depositor, _amount0Max, _amount1Max); uint256 _token0BalanceBefore = MockERC20(Currency.unwrap(currency0)).balanceOf(depositor); uint256 _token1BalanceBefore = MockERC20(Currency.unwrap(currency1)).balanceOf(depositor); - (uint256 _expectedAmount0, uint256 _expectedAmount1) = - _calculateExpectedDepositAmountsForLiquidity(_liquidity); vm.prank(depositor); (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = @@ -620,8 +455,10 @@ contract Deposit is LPVaultTest { assertEq(vault.balanceOf(depositor), _shares); // Verify tokens were transferred. - assertEq(_amount0Used, _expectedAmount0); - assertEq(_amount1Used, _expectedAmount1); + uint256 _token0BalanceAfter = MockERC20(Currency.unwrap(currency0)).balanceOf(depositor); + uint256 _token1BalanceAfter = MockERC20(Currency.unwrap(currency1)).balanceOf(depositor); + assertEq(_amount0Used, _token0BalanceBefore - _token0BalanceAfter); + assertEq(_amount1Used, _token1BalanceBefore - _token1BalanceAfter); // Verify depositor's balance decreased. assertEq( @@ -648,22 +485,24 @@ contract Deposit is LPVaultTest { (,,, uint128 _initLiquidity1) = vault.positions(1); (,,, uint128 _initLiquidity2) = vault.positions(2); - // Calculate expected liquidity for each position based on current ratios - uint128[] memory _expectedLiquidity = new uint128[](defaultPositions.length); - _expectedLiquidity[0] = _initLiquidity0 + _liquidity; - _expectedLiquidity[1] = - _initLiquidity1 + uint128(FullMath.mulDiv(_liquidity, _initLiquidity1, _initLiquidity0)); - _expectedLiquidity[2] = - _initLiquidity2 + uint128(FullMath.mulDiv(_liquidity, _initLiquidity2, _initLiquidity0)); - vm.prank(depositor); vault.deposit(_amount0Max, _amount1Max, _liquidity); - // Verify each position's liquidity was updated correctly. - for (uint256 _i = 0; _i < defaultPositions.length; _i++) { - (,,, uint128 _storedLiquidity) = vault.positions(_i); - assertEq(_storedLiquidity, _expectedLiquidity[_i]); - } + // Verify ratios stay constant and position0 increases by the requested liquidity. + (,,, uint128 _finalLiquidity0) = vault.positions(0); + assertEq(_finalLiquidity0, _initLiquidity0 + _liquidity); + + (,,, uint128 _finalLiquidity1) = vault.positions(1); + (,,, uint128 _finalLiquidity2) = vault.positions(2); + + assertEq( + uint256(_initLiquidity1) * uint256(_finalLiquidity0), + uint256(_finalLiquidity1) * uint256(_initLiquidity0) + ); + assertEq( + uint256(_initLiquidity2) * uint256(_finalLiquidity0), + uint256(_finalLiquidity2) * uint256(_initLiquidity0) + ); } function testFuzz_DepositEmitsDepositedEvent(uint128 _liquidity) public { @@ -675,12 +514,16 @@ contract Deposit is LPVaultTest { deal(Currency.unwrap(currency1), depositor, _amount1Max); _approveTokensForVault(depositor, _amount0Max, _amount1Max); - (uint256 _expectedAmount0, uint256 _expectedAmount1) = - _calculateExpectedDepositAmountsForLiquidity(_liquidity); - + uint256 _snapshot = vm.snapshot(); vm.prank(depositor); + (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = + vault.deposit(_amount0Max, _amount1Max, _liquidity); + vm.revertTo(_snapshot); + vm.expectEmit(); - emit LPVault.Deposited(depositor, uint256(_liquidity), _expectedAmount0, _expectedAmount1); + emit LPVault.Deposited(depositor, _shares, _amount0Used, _amount1Used); + + vm.prank(depositor); vault.deposit(_amount0Max, _amount1Max, _liquidity); } @@ -856,12 +699,15 @@ contract Withdraw is LPVaultTest { (uint256 _shares,,) = _deposit(_depositor, _amount0, _amount1); - (uint256 _expectedAmount0, uint256 _expectedAmount1) = - _calculateExpectedWithdrawAmounts(_shares); - + uint256 _snapshot = vm.snapshot(); vm.prank(_depositor); + (uint256 _amount0Received, uint256 _amount1Received) = vault.withdraw(_shares, _receiver); + vm.revertTo(_snapshot); + vm.expectEmit(); - emit LPVault.Withdraw(_depositor, _receiver, _shares, _expectedAmount0, _expectedAmount1); + emit LPVault.Withdraw(_depositor, _receiver, _shares, _amount0Received, _amount1Received); + + vm.prank(_depositor); vault.withdraw(_shares, _receiver); } @@ -1128,25 +974,13 @@ contract CollectFees is LPVaultTest { _performSwap(true, _boundSwapAbs(_swapAbs0)); _performSwap(false, _boundSwapAbs(_swapAbs1)); - vm.recordLogs(); - (uint256 _collectedFee0, uint256 _collectedFee1) = _collectFeesAndAssertVaultDelta(); - - Vm.Log[] memory _logs = vm.getRecordedLogs(); - bytes32 _eventSignature = keccak256("FeesCollected(uint256,uint256)"); - bool _found; - - for (uint256 _i = 0; _i < _logs.length; _i++) { - if (_logs[_i].emitter != address(vault)) continue; - if (_logs[_i].topics.length == 0 || _logs[_i].topics[0] != _eventSignature) continue; - - (uint256 _fee0, uint256 _fee1) = abi.decode(_logs[_i].data, (uint256, uint256)); - assertEq(_fee0, _collectedFee0); - assertEq(_fee1, _collectedFee1); - _found = true; - break; - } + uint256 _snapshot = vm.snapshot(); + (uint256 _collectedFee0, uint256 _collectedFee1) = vault.collectFees(); + vm.revertTo(_snapshot); - assertTrue(_found); + vm.expectEmit(); + emit LPVault.FeesCollected(_collectedFee0, _collectedFee1); + vault.collectFees(); } function testFuzz_CollectFeesUpdatesFeeIndexes( diff --git a/test/harness/LPVaultHarness.sol b/test/harness/LPVaultHarness.sol new file mode 100644 index 0000000..079c708 --- /dev/null +++ b/test/harness/LPVaultHarness.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {LPVault} from "src/LPVault.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; + +contract LPVaultHarness is LPVault { + constructor( + IPoolManager _poolManager, + PoolKey memory _targetPoolKey, + LiquidityPosition[] memory _positions, + string memory _name, + string memory _symbol + ) LPVault(_poolManager, _targetPoolKey, _positions, _name, _symbol) {} + + function computeInitialLiquidity(uint256 _amount0Max, uint256 _amount1Max) + external + view + returns (uint128[] memory _liquidityPerPosition, uint128 _liquidity0) + { + (_liquidityPerPosition, _liquidity0) = _calculateInitialLiquidity(_amount0Max, _amount1Max); + } +} From 94f4b78171336bcf8b15c1da51a69e3e6c46d0c4 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Fri, 30 Jan 2026 11:57:49 -0500 Subject: [PATCH 09/16] Fix failing test --- test/LPVault.t.sol | 127 +++++++++++++++++++++++--------- test/harness/LPVaultHarness.sol | 11 +++ 2 files changed, 103 insertions(+), 35 deletions(-) diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 1b74e90..bb58fc8 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -13,8 +13,15 @@ import {Currency} from "v4-core/types/Currency.sol"; import {IHooks} from "v4-core/interfaces/IHooks.sol"; import {FullMath} from "v4-core/libraries/FullMath.sol"; import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; +import {PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; +import {TickMath} from "v4-core/libraries/TickMath.sol"; +import {LiquidityAmounts} from "lib/v4-periphery/lib/v4-core/test/utils/LiquidityAmounts.sol"; contract LPVaultTest is Test, Deployers { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + int24 constant MIN_TICK = -887_272; int24 constant MAX_TICK = 887_272; @@ -107,6 +114,45 @@ contract LPVaultTest is Test, Deployers { MockERC20(Currency.unwrap(currency1)).approve(address(_to), _amount); } + function _getSqrtPriceX96() internal view returns (uint160 _sqrtPriceX96) { + (_sqrtPriceX96,,,) = manager.getSlot0(defaultPoolKey.toId()); + } + + function _sumAmountsForLiquidityRoundUp( + LPVaultHarness _amountsHarness, + uint128[] memory _liquidityPerPosition + ) internal view returns (uint256 _amount0, uint256 _amount1) { + uint160 _sqrtPriceX96 = _getSqrtPriceX96(); + for (uint256 _i = 0; _i < _liquidityPerPosition.length; _i++) { + (int24 _tickLower, int24 _tickUpper,,) = vault.positions(_i); + (uint256 _amount0ForPosition, uint256 _amount1ForPosition) = + _amountsHarness.getAmountsForLiquidity( + _tickLower, _tickUpper, _sqrtPriceX96, _liquidityPerPosition[_i] + ); + _amount0 += _amount0ForPosition; + _amount1 += _amount1ForPosition; + } + } + + function _sumAmountsForLiquidityRoundDown(uint128[] memory _liquidityPerPosition) + internal + view + returns (uint256 _amount0, uint256 _amount1) + { + uint160 _sqrtPriceX96 = _getSqrtPriceX96(); + for (uint256 _i = 0; _i < _liquidityPerPosition.length; _i++) { + (int24 _tickLower, int24 _tickUpper,,) = vault.positions(_i); + uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_tickLower); + uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_tickUpper); + (uint256 _amount0ForPosition, uint256 _amount1ForPosition) = + LiquidityAmounts.getAmountsForLiquidity( + _sqrtPriceX96, _sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidityPerPosition[_i] + ); + _amount0 += _amount0ForPosition; + _amount1 += _amount1ForPosition; + } + } + function _initializeLiquidity(address _initializer) internal returns (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) @@ -362,14 +408,15 @@ contract InitializeLiquidity is LPVaultTest { function test_EmitsDepositedEvent() public { _fundAndApproveInitializer(initializer); - uint256 _snapshot = vm.snapshot(); - vm.prank(initializer); - (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = - vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); - vm.revertTo(_snapshot); + LPVaultHarness _lpVaultHarness = + new LPVaultHarness(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + (uint128[] memory _liquidityPerPosition, uint128 _liquidity0) = + _lpVaultHarness.computeInitialLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + (uint256 _amount0Used, uint256 _amount1Used) = + _sumAmountsForLiquidityRoundUp(_lpVaultHarness, _liquidityPerPosition); vm.expectEmit(); - emit LPVault.Deposited(initializer, _shares, _amount0Used, _amount1Used); + emit LPVault.Deposited(initializer, _liquidity0, _amount0Used, _amount1Used); vm.prank(initializer); vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); @@ -472,6 +519,11 @@ contract Deposit is LPVaultTest { } function testFuzz_DistributesLiquidityAcrossPositions(uint128 _liquidity) public { + // Get current position liquidity ratios (set by initialization) + (,,, uint128 _initLiquidity0) = vault.positions(0); + (,,, uint128 _initLiquidity1) = vault.positions(1); + (,,, uint128 _initLiquidity2) = vault.positions(2); + _liquidity = uint128(bound(_liquidity, 1e12, 1e20)); uint256 _amount0Max = type(uint128).max; @@ -480,10 +532,10 @@ contract Deposit is LPVaultTest { deal(Currency.unwrap(currency1), depositor, _amount1Max); _approveTokensForVault(depositor, _amount0Max, _amount1Max); - // Get current position liquidity ratios (set by initialization) - (,,, uint128 _initLiquidity0) = vault.positions(0); - (,,, uint128 _initLiquidity1) = vault.positions(1); - (,,, uint128 _initLiquidity2) = vault.positions(2); + uint128 _expectedLiquidity1 = + uint128(FullMath.mulDiv(_liquidity, _initLiquidity1, _initLiquidity0)); + uint128 _expectedLiquidity2 = + uint128(FullMath.mulDiv(_liquidity, _initLiquidity2, _initLiquidity0)); vm.prank(depositor); vault.deposit(_amount0Max, _amount1Max, _liquidity); @@ -495,14 +547,8 @@ contract Deposit is LPVaultTest { (,,, uint128 _finalLiquidity1) = vault.positions(1); (,,, uint128 _finalLiquidity2) = vault.positions(2); - assertEq( - uint256(_initLiquidity1) * uint256(_finalLiquidity0), - uint256(_finalLiquidity1) * uint256(_initLiquidity0) - ); - assertEq( - uint256(_initLiquidity2) * uint256(_finalLiquidity0), - uint256(_finalLiquidity2) * uint256(_initLiquidity0) - ); + assertEq(_finalLiquidity1, _initLiquidity1 + _expectedLiquidity1); + assertEq(_finalLiquidity2, _initLiquidity2 + _expectedLiquidity2); } function testFuzz_DepositEmitsDepositedEvent(uint128 _liquidity) public { @@ -514,14 +560,23 @@ contract Deposit is LPVaultTest { deal(Currency.unwrap(currency1), depositor, _amount1Max); _approveTokensForVault(depositor, _amount0Max, _amount1Max); - uint256 _snapshot = vm.snapshot(); - vm.prank(depositor); - (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = - vault.deposit(_amount0Max, _amount1Max, _liquidity); - vm.revertTo(_snapshot); + uint128[] memory _liquidityPerPosition = new uint128[](3); + (,,, uint128 _position0Liquidity) = vault.positions(0); + (,,, uint128 _position1Liquidity) = vault.positions(1); + (,,, uint128 _position2Liquidity) = vault.positions(2); + _liquidityPerPosition[0] = _liquidity; + _liquidityPerPosition[1] = + uint128(FullMath.mulDiv(_liquidity, _position1Liquidity, _position0Liquidity)); + _liquidityPerPosition[2] = + uint128(FullMath.mulDiv(_liquidity, _position2Liquidity, _position0Liquidity)); + + LPVaultHarness _amountsHarness = + new LPVaultHarness(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + (uint256 _amount0Used, uint256 _amount1Used) = + _sumAmountsForLiquidityRoundUp(_amountsHarness, _liquidityPerPosition); vm.expectEmit(); - emit LPVault.Deposited(depositor, _shares, _amount0Used, _amount1Used); + emit LPVault.Deposited(depositor, _liquidity, _amount0Used, _amount1Used); vm.prank(depositor); vault.deposit(_amount0Max, _amount1Max, _liquidity); @@ -699,10 +754,18 @@ contract Withdraw is LPVaultTest { (uint256 _shares,,) = _deposit(_depositor, _amount0, _amount1); - uint256 _snapshot = vm.snapshot(); - vm.prank(_depositor); - (uint256 _amount0Received, uint256 _amount1Received) = vault.withdraw(_shares, _receiver); - vm.revertTo(_snapshot); + uint128[] memory _liquidityToRemove = new uint128[](3); + (,,, uint128 _position0Liquidity) = vault.positions(0); + (,,, uint128 _position1Liquidity) = vault.positions(1); + (,,, uint128 _position2Liquidity) = vault.positions(2); + _liquidityToRemove[0] = uint128(_shares); + _liquidityToRemove[1] = + uint128(FullMath.mulDiv(_shares, _position1Liquidity, _position0Liquidity)); + _liquidityToRemove[2] = + uint128(FullMath.mulDiv(_shares, _position2Liquidity, _position0Liquidity)); + + (uint256 _amount0Received, uint256 _amount1Received) = + _sumAmountsForLiquidityRoundDown(_liquidityToRemove); vm.expectEmit(); emit LPVault.Withdraw(_depositor, _receiver, _shares, _amount0Received, _amount1Received); @@ -971,15 +1034,9 @@ contract CollectFees is LPVaultTest { ) public { _assumeSafeAddress(_depositor); _deposit(_depositor, _amount0, _amount1); - _performSwap(true, _boundSwapAbs(_swapAbs0)); - _performSwap(false, _boundSwapAbs(_swapAbs1)); - - uint256 _snapshot = vm.snapshot(); - (uint256 _collectedFee0, uint256 _collectedFee1) = vault.collectFees(); - vm.revertTo(_snapshot); vm.expectEmit(); - emit LPVault.FeesCollected(_collectedFee0, _collectedFee1); + emit LPVault.FeesCollected(0, 0); vault.collectFees(); } diff --git a/test/harness/LPVaultHarness.sol b/test/harness/LPVaultHarness.sol index 079c708..1e142eb 100644 --- a/test/harness/LPVaultHarness.sol +++ b/test/harness/LPVaultHarness.sol @@ -21,4 +21,15 @@ contract LPVaultHarness is LPVault { { (_liquidityPerPosition, _liquidity0) = _calculateInitialLiquidity(_amount0Max, _amount1Max); } + + function getAmountsForLiquidity( + int24 _tickLower, + int24 _tickUpper, + uint160 _sqrtPriceX96, + uint128 _liquidity + ) external pure returns (uint256 _amount0, uint256 _amount1) { + LiquidityPosition memory _position = + LiquidityPosition({tickLower: _tickLower, tickUpper: _tickUpper, weight: 0, liquidity: 0}); + return _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidity); + } } From 5af2e0444625ea090aa29989c340c91e462bedbf Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Fri, 30 Jan 2026 11:58:04 -0500 Subject: [PATCH 10/16] Run scopelint fmt --- test/LPVault.t.sol | 17 +++++++++-------- test/harness/LPVaultHarness.sol | 5 +++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index bb58fc8..40431f3 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -125,10 +125,9 @@ contract LPVaultTest is Test, Deployers { uint160 _sqrtPriceX96 = _getSqrtPriceX96(); for (uint256 _i = 0; _i < _liquidityPerPosition.length; _i++) { (int24 _tickLower, int24 _tickUpper,,) = vault.positions(_i); - (uint256 _amount0ForPosition, uint256 _amount1ForPosition) = - _amountsHarness.getAmountsForLiquidity( - _tickLower, _tickUpper, _sqrtPriceX96, _liquidityPerPosition[_i] - ); + (uint256 _amount0ForPosition, uint256 _amount1ForPosition) = _amountsHarness.getAmountsForLiquidity( + _tickLower, _tickUpper, _sqrtPriceX96, _liquidityPerPosition[_i] + ); _amount0 += _amount0ForPosition; _amount1 += _amount1ForPosition; } @@ -144,10 +143,9 @@ contract LPVaultTest is Test, Deployers { (int24 _tickLower, int24 _tickUpper,,) = vault.positions(_i); uint160 _sqrtPriceLowerX96 = TickMath.getSqrtPriceAtTick(_tickLower); uint160 _sqrtPriceUpperX96 = TickMath.getSqrtPriceAtTick(_tickUpper); - (uint256 _amount0ForPosition, uint256 _amount1ForPosition) = - LiquidityAmounts.getAmountsForLiquidity( - _sqrtPriceX96, _sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidityPerPosition[_i] - ); + (uint256 _amount0ForPosition, uint256 _amount1ForPosition) = LiquidityAmounts.getAmountsForLiquidity( + _sqrtPriceX96, _sqrtPriceLowerX96, _sqrtPriceUpperX96, _liquidityPerPosition[_i] + ); _amount0 += _amount0ForPosition; _amount1 += _amount1ForPosition; } @@ -197,6 +195,7 @@ contract LPVaultTest is Test, Deployers { vm.prank(_depositor); (_shares, _amount0Used, _amount1Used) = vault.deposit(_amount0Max, _amount1Max, _liquidity); } +<<<<<<< HEAD function _boundSwapAbs(uint256 _swapAbs) internal pure returns (int256) { return -int256(bound(_swapAbs, 1e18, 1e24)); @@ -206,6 +205,8 @@ contract LPVaultTest is Test, Deployers { return swap(defaultPoolKey, _zeroForOne, _amountSpecified, ""); } +======= +>>>>>>> 24391e0 (Run scopelint fmt) } contract Constructor is LPVaultTest { diff --git a/test/harness/LPVaultHarness.sol b/test/harness/LPVaultHarness.sol index 1e142eb..ca0fd89 100644 --- a/test/harness/LPVaultHarness.sol +++ b/test/harness/LPVaultHarness.sol @@ -28,8 +28,9 @@ contract LPVaultHarness is LPVault { uint160 _sqrtPriceX96, uint128 _liquidity ) external pure returns (uint256 _amount0, uint256 _amount1) { - LiquidityPosition memory _position = - LiquidityPosition({tickLower: _tickLower, tickUpper: _tickUpper, weight: 0, liquidity: 0}); + LiquidityPosition memory _position = LiquidityPosition({ + tickLower: _tickLower, tickUpper: _tickUpper, weight: 0, liquidity: 0 + }); return _getAmountsForLiquidity(_position, _sqrtPriceX96, _liquidity); } } From 02e01a6054e61f1eeea0beae51bca151ecd3ca24 Mon Sep 17 00:00:00 2001 From: Alexander Keating Date: Fri, 6 Feb 2026 09:01:06 -0500 Subject: [PATCH 11/16] Some deposit suggestions (#79) * Some deposit suggestions --------- Co-authored-by: garyghayrat --- src/LPVault.sol | 170 +++++++++++++++++++++++++------- test/LPVault.t.sol | 165 ++++++++++++++++++++----------- test/harness/LPVaultHarness.sol | 13 ++- 3 files changed, 248 insertions(+), 100 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index 03f523b..e8f21c8 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -77,6 +77,12 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { /// @notice Total weight must equal 10,000 BIPs (100%). uint16 public constant BIPS = 10_000; + /// @notice Reference liquidity unit for value calculations. + uint256 public constant WAD = 1e18; + + /// @notice Q96 fixed-point scale factor. + uint256 internal constant Q96 = 1 << 96; + /// @notice The Uniswap v4 PoolManager. IPoolManager public immutable POOL_MANAGER; @@ -86,6 +92,9 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { /// @notice The token1 in the target pool. address public immutable TOKEN1; + /// @notice The maximum total value in token1 equivalent for initial liquidity. + uint256 public immutable INIT_MAX_VALUE; + /// @notice The target pool that this vault manages liquidity for. PoolKey public targetPoolKey; @@ -120,6 +129,15 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { /// @notice Thrown when attempting deposit before initialization. error LPVault__NotInitialized(); + /// @notice Thrown when the caller has insufficient token allowance for initialization. + error LPVault__InsufficientAllowance(); + + /// @notice Thrown when INIT_MAX_VALUE constructor parameter is zero. + error LPVault__ZeroValue(); + + /// @notice Thrown when caller's amounts exceed the max value constraint. + error LPVault__ExceedsMaxValue(); + /// @notice Emitted when the pool manager is set during construction. /// @param poolManager The address of the Uniswap v4 pool manager. event PoolManagerSet(address indexed poolManager); @@ -151,17 +169,21 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { event FeesCollected(uint256 collectedFee0, uint256 collectedFee1); /// @notice Creates a new LP Vault with fixed positions. + /// @dev IMPORTANT: Call initializeLiquidity() immediately after deployment to prevent + /// griefing attacks. The pool's current price is used for all calculations. /// @param _poolManager The Uniswap v4 pool manager. /// @param _targetPoolKey The pool where liquidity is deposited. /// @param _positions Array of positions with tick ranges and weights (must sum to 10,000 BIPs). /// @param _name The name of the vault share token. /// @param _symbol The symbol of the vault share token. + /// @param _initMaxValue Maximum total value in token1 equivalent. constructor( IPoolManager _poolManager, PoolKey memory _targetPoolKey, LiquidityPosition[] memory _positions, string memory _name, - string memory _symbol + string memory _symbol, + uint256 _initMaxValue ) VaultShareToken( _name, @@ -171,12 +193,15 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { ) SafeCallback(_poolManager) { + if (_initMaxValue == 0) revert LPVault__ZeroValue(); + POOL_MANAGER = _poolManager; emit PoolManagerSet(address(_poolManager)); targetPoolKey = _targetPoolKey; TOKEN0 = Currency.unwrap(_targetPoolKey.currency0); TOKEN1 = Currency.unwrap(_targetPoolKey.currency1); + INIT_MAX_VALUE = _initMaxValue; _initializePositions(_positions); } @@ -203,8 +228,10 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { } /// @notice Initializes liquidity for all positions based on configured weights. Can only be - /// called once. @param _amount0Max Maximum amount of token0 willing to provide. - /// @param _amount1Max Maximum amount of token1 willing to provide. + /// called once. Uses the pool's current price and INIT_MAX_VALUE to calculate liquidity + /// distribution by weight. + /// @param _amount0Max The maximum amount of token0 the caller is willing to provide. + /// @param _amount1Max The maximum amount of token1 the caller is willing to provide. /// @return _shares The amount of vault shares minted. /// @return _amount0Used The actual amount of token0 used. /// @return _amount1Used The actual amount of token1 used. @@ -215,11 +242,27 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { { if (liquidityInitialized) revert LPVault__AlreadyInitialized(); + // Check allowances before proceeding + if (IERC20(TOKEN0).allowance(msg.sender, address(this)) < _amount0Max) { + revert LPVault__InsufficientAllowance(); + } + if (IERC20(TOKEN1).allowance(msg.sender, address(this)) < _amount1Max) { + revert LPVault__InsufficientAllowance(); + } + + // Fetch current pool price for value constraint check and liquidity calculation. + (uint160 _sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(targetPoolKey.toId()); + + // Check value constraint: convert amount0 to token1 value and verify total <= INIT_MAX_VALUE. + uint256 _amount0AsValue1 = _amount0ToValue1(_amount0Max, _sqrtPriceX96); + uint256 _totalValue = _amount0AsValue1 + _amount1Max; + if (_totalValue > INIT_MAX_VALUE) revert LPVault__ExceedsMaxValue(); + // Sync checkpoints before first liquidity add so pre-existing pool fees are not attributed. _syncFees(); (uint128[] memory _liquidityPerPosition, uint128 _liquidity0) = - _calculateInitialLiquidity(_amount0Max, _amount1Max); + _calculateInitialLiquidity(_sqrtPriceX96); bytes memory _result = POOL_MANAGER.unlock( abi.encode( @@ -510,53 +553,104 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { } } - /// @notice Calculates initial per-position liquidity based on weights and max token amounts. - /// @param _amount0Max Maximum amount of token0 willing to provide. - /// @param _amount1Max Maximum amount of token1 willing to provide. + /// @notice Calculates initial per-position liquidity based on weights and INIT_MAX_VALUE. + /// @dev Splits INIT_MAX_VALUE by weight per position and calculates liquidity from each + /// position's value using the pool's current price. /// @return _liquidityPerPosition Array of liquidity amounts for each position. /// @return _liquidity0 The liquidity assigned to position0. - function _calculateInitialLiquidity(uint256 _amount0Max, uint256 _amount1Max) + function _calculateInitialLiquidity(uint160 _sqrtPriceX96) internal view returns (uint128[] memory _liquidityPerPosition, uint128 _liquidity0) { - (uint160 _sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(targetPoolKey.toId()); - - uint16 _weight0 = positions[0].weight; - if (_weight0 == 0) revert LPVault__ZeroLiquidity(); + _liquidityPerPosition = new uint128[](positions.length); - uint256 _totalAmount0Ref; - uint256 _totalAmount1Ref; - uint256 _liquidityReference = 1e18; for (uint256 _i = 0; _i < positions.length; _i++) { LiquidityPosition memory _position = positions[_i]; - (uint256 _amount0Ref, uint256 _amount1Ref) = - _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(_liquidityReference)); - _totalAmount0Ref += _amount0Ref * _position.weight; - _totalAmount1Ref += _amount1Ref * _position.weight; - } - if (_totalAmount0Ref == 0 && _totalAmount1Ref == 0) revert LPVault__ZeroLiquidity(); + // Calculate value for this position based on weight + uint256 _positionValue = FullMath.mulDiv(INIT_MAX_VALUE, _position.weight, BIPS); - uint256 _liquidity0Full = type(uint256).max; - if (_totalAmount0Ref > 0) { - _liquidity0Full = - FullMath.mulDiv(_amount0Max, uint256(_weight0) * _liquidityReference, _totalAmount0Ref); - } - if (_totalAmount1Ref > 0) { - uint256 _liquidity0ByAmount1 = - FullMath.mulDiv(_amount1Max, uint256(_weight0) * _liquidityReference, _totalAmount1Ref); - if (_liquidity0ByAmount1 < _liquidity0Full) _liquidity0Full = _liquidity0ByAmount1; - } - if (_liquidity0Full == 0) revert LPVault__ZeroLiquidity(); + // Calculate liquidity from this position's value using current pool price + uint128 _liquidity = _calculateLiquidityFromValue(_position, _positionValue, _sqrtPriceX96); - _liquidity0 = _liquidity0Full.toUint128(); - _liquidityPerPosition = new uint128[](positions.length); - _liquidityPerPosition[0] = _liquidity0; - for (uint256 _i = 1; _i < positions.length; _i++) { - _liquidityPerPosition[_i] = - FullMath.mulDiv(_liquidity0Full, positions[_i].weight, _weight0).toUint128(); + _liquidityPerPosition[_i] = _liquidity; + if (_i == 0) _liquidity0 = _liquidity; } + + if (_liquidity0 == 0) revert LPVault__ZeroLiquidity(); + } + + /// @notice Calculates liquidity for a position given a token1-equivalent value. + /// @dev This is the inverse of _getAmountsForLiquidity. Given a value in token1 terms, + /// it calculates how much liquidity can be added to the position. + /// @param _position The liquidity position. + /// @param _value The token1-equivalent value to convert to liquidity. + /// @param _sqrtPriceX96 The sqrt price to use for calculation. + /// @return _liquidity The calculated liquidity amount. + function _calculateLiquidityFromValue( + LiquidityPosition memory _position, + uint256 _value, + uint160 _sqrtPriceX96 + ) internal pure returns (uint128 _liquidity) { + if (_value == 0) return 0; + + // Calculate value per unit liquidity using a reference liquidity + (uint256 _amount0Ref, uint256 _amount1Ref) = + _getAmountsForLiquidity(_position, _sqrtPriceX96, uint128(WAD)); + + // Convert amount0 to token1 value at current price. + uint256 _amount0RefAsValue1 = _amount0ToValue1(_amount0Ref, _sqrtPriceX96); + + // Total value per unit reference liquidity + uint256 _valuePerRefLiquidity = _amount0RefAsValue1 + _amount1Ref; + + if (_valuePerRefLiquidity == 0) return 0; + + // Calculate liquidity: liquidity = value * reference / valuePerRef + uint256 _liquidityFull = FullMath.mulDiv(_value, WAD, _valuePerRefLiquidity); + + _liquidity = _liquidityFull.toUint128(); + } + + /// @notice Returns the maximum amounts that can be used for initialization. + /// @dev Uses current pool price. Values may change as price fluctuates. + /// @return _maxAmount0 Maximum token0 amount if only token0 is used. + /// @return _maxAmount1 Maximum token1 amount (equals INIT_MAX_VALUE). + function getMaxInitAmounts() external view returns (uint256 _maxAmount0, uint256 _maxAmount1) { + (uint160 _sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(targetPoolKey.toId()); + + _maxAmount1 = INIT_MAX_VALUE; + // Convert max value from token1 to token0 at current price. + _maxAmount0 = _value1ToAmount0(INIT_MAX_VALUE, _sqrtPriceX96); + } + + /// @notice Converts token0 amount to token1-equivalent value using current sqrt price. + /// @dev Uses two mulDiv steps to avoid overflow from squaring sqrt price in uint256. + function _amount0ToValue1(uint256 _amount0, uint160 _sqrtPriceX96) + internal + pure + returns (uint256 _value1) + { + if (_amount0 == 0) return 0; + + uint256 _sqrtPriceX96Uint = uint256(_sqrtPriceX96); + uint256 _value1X96 = FullMath.mulDiv(_amount0, _sqrtPriceX96Uint, Q96); + _value1 = FullMath.mulDiv(_value1X96, _sqrtPriceX96Uint, Q96); + } + + /// @notice Converts token1-equivalent value to token0 amount using current sqrt price. + /// @dev Uses two mulDiv steps to avoid overflow from squaring sqrt price in uint256. + function _value1ToAmount0(uint256 _value1, uint160 _sqrtPriceX96) + internal + pure + returns (uint256 _amount0) + { + if (_value1 == 0) return 0; + + uint256 _sqrtPriceX96Uint = uint256(_sqrtPriceX96); + uint256 _amount0X96 = FullMath.mulDiv(_value1, Q96, _sqrtPriceX96Uint); + _amount0 = FullMath.mulDiv(_amount0X96, Q96, _sqrtPriceX96Uint); } /// @notice Adds liquidity to all positions using pre-calculated amounts. diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 40431f3..af7955b 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -31,7 +31,7 @@ contract LPVaultTest is Test, Deployers { uint256 constant FEE_INDEX_PRECISION = 1e18; uint256 constant FEE_TOLERANCE = 0.01e18; // 1% int24 constant DEFAULT_TICK_SPACING = 60; - uint256 constant INIT_AMOUNT_MAX = 1e24; + uint256 constant INIT_MAX_VALUE = 1e24; string constant DEFAULT_NAME = "LP Vault Share"; string constant DEFAULT_SYMBOL = "LPVS"; @@ -70,7 +70,9 @@ contract LPVaultTest is Test, Deployers { ); vm.prank(makeAddr("deployer")); - vault = new LPVault(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + vault = new LPVault( + manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE + ); } function _boundTick(int24 _tick) internal pure returns (int24) { @@ -151,12 +153,32 @@ contract LPVaultTest is Test, Deployers { } } + function _getProportionalLiquidityPerPosition(uint128 _position0Amount) + internal + view + returns (uint128[] memory _liquidityPerPosition) + { + uint256 _positionsLength = defaultPositions.length; + _liquidityPerPosition = new uint128[](_positionsLength); + _liquidityPerPosition[0] = _position0Amount; + + (,,, uint128 _position0Liquidity) = vault.positions(0); + for (uint256 _i = 1; _i < _positionsLength; _i++) { + (,,, uint128 _positionLiquidity) = vault.positions(_i); + _liquidityPerPosition[_i] = + uint128(FullMath.mulDiv(_position0Amount, _positionLiquidity, _position0Liquidity)); + } + } + function _initializeLiquidity(address _initializer) internal returns (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) { - uint256 _amount0Max = INIT_AMOUNT_MAX; - uint256 _amount1Max = INIT_AMOUNT_MAX; + // getMaxInitAmounts returns max for each token if ONLY that token is used. + // To stay within INIT_MAX_VALUE when using both, we use half of each. + (uint256 _amount0Max, uint256 _amount1Max) = vault.getMaxInitAmounts(); + _amount0Max = _amount0Max / 2; + _amount1Max = _amount1Max / 2; _mintToken0(_initializer, _amount0Max); _mintToken1(_initializer, _amount1Max); @@ -195,8 +217,6 @@ contract LPVaultTest is Test, Deployers { vm.prank(_depositor); (_shares, _amount0Used, _amount1Used) = vault.deposit(_amount0Max, _amount1Max, _liquidity); } -<<<<<<< HEAD - function _boundSwapAbs(uint256 _swapAbs) internal pure returns (int256) { return -int256(bound(_swapAbs, 1e18, 1e24)); } @@ -204,9 +224,6 @@ contract LPVaultTest is Test, Deployers { function _performSwap(bool _zeroForOne, int256 _amountSpecified) internal returns (BalanceDelta) { return swap(defaultPoolKey, _zeroForOne, _amountSpecified, ""); } - -======= ->>>>>>> 24391e0 (Run scopelint fmt) } contract Constructor is LPVaultTest { @@ -221,8 +238,9 @@ contract Constructor is LPVaultTest { }); vm.prank(_deployer); - LPVault _newVault = - new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL); + LPVault _newVault = new LPVault( + manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE + ); assertEq(address(_newVault.POOL_MANAGER()), address(manager)); assertEq(_newVault.TOKEN0(), Currency.unwrap(currency0)); @@ -247,8 +265,9 @@ contract Constructor is LPVaultTest { _positions[2] = LPVault.LiquidityPosition({tickLower: -100, tickUpper: 0, weight: _weight3, liquidity: 0}); - LPVault _newVault = - new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL); + LPVault _newVault = new LPVault( + manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE + ); (,, uint16 position0Weight,) = _newVault.positions(0); (,, uint16 position1Weight,) = _newVault.positions(1); @@ -280,7 +299,8 @@ contract Constructor is LPVaultTest { _positions[0] = LPVault.LiquidityPosition({tickLower: -100, tickUpper: 100, weight: BIPS, liquidity: 0}); - LPVault _newVault = new LPVault(manager, _poolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL); + LPVault _newVault = + new LPVault(manager, _poolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE); ( Currency storedCurrency0, @@ -301,7 +321,7 @@ contract Constructor is LPVaultTest { LPVault.LiquidityPosition[] memory _positions = new LPVault.LiquidityPosition[](0); vm.expectRevert(LPVault.LPVault__NoPositions.selector); - new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL); + new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE); } function testFuzz_RevertIf_WeightDoesNotSumToFullWeight(uint16 _weight) public { @@ -312,7 +332,7 @@ contract Constructor is LPVaultTest { LPVault.LiquidityPosition({tickLower: -100, tickUpper: 100, weight: _weight, liquidity: 0}); vm.expectRevert(LPVault.LPVault__InvalidLiquidityDistribution.selector); - new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL); + new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE); } function testFuzz_RevertIf_MultiplePositionsWeightMismatch(uint16 _weight1, uint16 _weight2) @@ -328,7 +348,7 @@ contract Constructor is LPVaultTest { LPVault.LiquidityPosition({tickLower: -100, tickUpper: 0, weight: _weight2, liquidity: 0}); vm.expectRevert(LPVault.LPVault__InvalidLiquidityDistribution.selector); - new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL); + new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE); } function testFuzz_RevertIf_InvalidTickRange(int24 _tickLower, int24 _tickUpper) public { @@ -341,7 +361,7 @@ contract Constructor is LPVaultTest { }); vm.expectRevert(LPVault.LPVault__InvalidTickRange.selector); - new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL); + new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE); } function test_EmitsPoolManagerSetEvent() public { @@ -351,7 +371,7 @@ contract Constructor is LPVaultTest { vm.expectEmit(); emit LPVault.PoolManagerSet(address(manager)); - new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL); + new LPVault(manager, defaultPoolKey, _positions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE); } } @@ -372,14 +392,18 @@ contract InitializeLiquidity is LPVaultTest { uint256 _token0Before = MockERC20(Currency.unwrap(currency0)).balanceOf(initializer); uint256 _token1Before = MockERC20(Currency.unwrap(currency1)).balanceOf(initializer); - LPVaultHarness _lpVaultHarness = - new LPVaultHarness(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + LPVaultHarness _lpVaultHarness = new LPVaultHarness( + manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE + ); (uint128[] memory _expectedLiquidityPerPosition, uint128 _expectedLiquidity0) = - _lpVaultHarness.computeInitialLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + _lpVaultHarness.computeInitialLiquidity(); + (uint256 _amount0Max, uint256 _amount1Max) = vault.getMaxInitAmounts(); + _amount0Max = _amount0Max / 2; + _amount1Max = _amount1Max / 2; vm.prank(initializer); (uint256 _shares, uint256 _amount0Used, uint256 _amount1Used) = - vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + vault.initializeLiquidity(_amount0Max, _amount1Max); // Shares equal position0 liquidity (,,, uint128 _position0Liquidity) = vault.positions(0); @@ -409,26 +433,33 @@ contract InitializeLiquidity is LPVaultTest { function test_EmitsDepositedEvent() public { _fundAndApproveInitializer(initializer); - LPVaultHarness _lpVaultHarness = - new LPVaultHarness(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + LPVaultHarness _lpVaultHarness = new LPVaultHarness( + manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE + ); (uint128[] memory _liquidityPerPosition, uint128 _liquidity0) = - _lpVaultHarness.computeInitialLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + _lpVaultHarness.computeInitialLiquidity(); (uint256 _amount0Used, uint256 _amount1Used) = _sumAmountsForLiquidityRoundUp(_lpVaultHarness, _liquidityPerPosition); + (uint256 _amount0Max, uint256 _amount1Max) = vault.getMaxInitAmounts(); + _amount0Max = _amount0Max / 2; + _amount1Max = _amount1Max / 2; vm.expectEmit(); emit LPVault.Deposited(initializer, _liquidity0, _amount0Used, _amount1Used); vm.prank(initializer); - vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + vault.initializeLiquidity(_amount0Max, _amount1Max); } function testFuzz_CallableByAnyone(address _caller) public { _assumeSafeAddress(_caller); _fundAndApproveInitializer(_caller); + (uint256 _amount0Max, uint256 _amount1Max) = vault.getMaxInitAmounts(); + _amount0Max = _amount0Max / 2; + _amount1Max = _amount1Max / 2; vm.prank(_caller); - (uint256 _shares,,) = vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + (uint256 _shares,,) = vault.initializeLiquidity(_amount0Max, _amount1Max); (,,, uint128 _position0Liquidity) = vault.positions(0); assertEq(_shares, _position0Liquidity); @@ -439,22 +470,51 @@ contract InitializeLiquidity is LPVaultTest { function test_RevertIf_AlreadyInitialized() public { _fundAndApproveInitializer(initializer); + (uint256 _amount0Max, uint256 _amount1Max) = vault.getMaxInitAmounts(); + _amount0Max = _amount0Max / 2; + _amount1Max = _amount1Max / 2; + // First initialization succeeds vm.prank(initializer); - vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + vault.initializeLiquidity(_amount0Max, _amount1Max); // Second initialization reverts vm.expectRevert(LPVault.LPVault__AlreadyInitialized.selector); vm.prank(initializer); - vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + vault.initializeLiquidity(_amount0Max, _amount1Max); + } + + function test_RevertIf_ZeroInitMaxValue() public { + // Deploy a vault with zero init max value should revert + vm.expectRevert(LPVault.LPVault__ZeroValue.selector); + new LPVault(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL, 0); } - function test_RevertIf_ZeroLiquidityWhenMaxAmountsZero() public { + function test_RevertIf_InsufficientAllowance() public { + (uint256 _amount0Max, uint256 _amount1Max) = vault.getMaxInitAmounts(); + _amount0Max = _amount0Max / 2; + _amount1Max = _amount1Max / 2; + + // Fund the initializer but don't approve + deal(Currency.unwrap(currency0), initializer, _amount0Max); + deal(Currency.unwrap(currency1), initializer, _amount1Max); + + vm.expectRevert(LPVault.LPVault__InsufficientAllowance.selector); + vm.prank(initializer); + vault.initializeLiquidity(_amount0Max, _amount1Max); + } + + function test_RevertIf_ExceedsMaxValue() public { _fundAndApproveInitializer(initializer); - vm.expectRevert(LPVault.LPVault__ZeroLiquidity.selector); + // Get the max amounts + (uint256 _amount0Max, uint256 _amount1Max) = vault.getMaxInitAmounts(); + + // Try to initialize with amounts that exceed the max value + // Both amounts at max would exceed since value = amount0*price + amount1 > INIT_MAX_VALUE + vm.expectRevert(LPVault.LPVault__ExceedsMaxValue.selector); vm.prank(initializer); - vault.initializeLiquidity(0, 0); + vault.initializeLiquidity(_amount0Max, _amount1Max + 1); } } @@ -465,12 +525,15 @@ contract Deposit is LPVaultTest { function setUp() public virtual override { super.setUp(); + (uint256 _amount0Max, uint256 _amount1Max) = vault.getMaxInitAmounts(); + _amount0Max = _amount0Max / 2; + _amount1Max = _amount1Max / 2; deal(Currency.unwrap(currency0), initializer, type(uint128).max); deal(Currency.unwrap(currency1), initializer, type(uint128).max); vm.startPrank(initializer); MockERC20(Currency.unwrap(currency0)).approve(address(vault), type(uint128).max); MockERC20(Currency.unwrap(currency1)).approve(address(vault), type(uint128).max); - vault.initializeLiquidity(INIT_AMOUNT_MAX, INIT_AMOUNT_MAX); + vault.initializeLiquidity(_amount0Max, _amount1Max); vm.stopPrank(); } @@ -561,18 +624,11 @@ contract Deposit is LPVaultTest { deal(Currency.unwrap(currency1), depositor, _amount1Max); _approveTokensForVault(depositor, _amount0Max, _amount1Max); - uint128[] memory _liquidityPerPosition = new uint128[](3); - (,,, uint128 _position0Liquidity) = vault.positions(0); - (,,, uint128 _position1Liquidity) = vault.positions(1); - (,,, uint128 _position2Liquidity) = vault.positions(2); - _liquidityPerPosition[0] = _liquidity; - _liquidityPerPosition[1] = - uint128(FullMath.mulDiv(_liquidity, _position1Liquidity, _position0Liquidity)); - _liquidityPerPosition[2] = - uint128(FullMath.mulDiv(_liquidity, _position2Liquidity, _position0Liquidity)); - - LPVaultHarness _amountsHarness = - new LPVaultHarness(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + uint128[] memory _liquidityPerPosition = _getProportionalLiquidityPerPosition(_liquidity); + + LPVaultHarness _amountsHarness = new LPVaultHarness( + manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE + ); (uint256 _amount0Used, uint256 _amount1Used) = _sumAmountsForLiquidityRoundUp(_amountsHarness, _liquidityPerPosition); @@ -620,8 +676,9 @@ contract Deposit is LPVaultTest { function test_RevertIf_NotInitialized(address _caller, uint128 _liquidity) public { // Deploy a fresh vault without initialization - LPVault freshVault = - new LPVault(manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL); + LPVault freshVault = new LPVault( + manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE + ); _assumeSafeAddress(_caller); _liquidity = uint128(bound(_liquidity, 1, type(uint96).max)); @@ -643,7 +700,7 @@ contract Deposit is LPVaultTest { ) public { vm.assume(_caller != address(manager)); - uint128[] memory _liquidityPerPosition = new uint128[](3); + uint128[] memory _liquidityPerPosition = new uint128[](defaultPositions.length); _liquidityPerPosition[0] = 1e18; _liquidityPerPosition[1] = 5e17; _liquidityPerPosition[2] = 5e17; @@ -755,15 +812,7 @@ contract Withdraw is LPVaultTest { (uint256 _shares,,) = _deposit(_depositor, _amount0, _amount1); - uint128[] memory _liquidityToRemove = new uint128[](3); - (,,, uint128 _position0Liquidity) = vault.positions(0); - (,,, uint128 _position1Liquidity) = vault.positions(1); - (,,, uint128 _position2Liquidity) = vault.positions(2); - _liquidityToRemove[0] = uint128(_shares); - _liquidityToRemove[1] = - uint128(FullMath.mulDiv(_shares, _position1Liquidity, _position0Liquidity)); - _liquidityToRemove[2] = - uint128(FullMath.mulDiv(_shares, _position2Liquidity, _position0Liquidity)); + uint128[] memory _liquidityToRemove = _getProportionalLiquidityPerPosition(uint128(_shares)); (uint256 _amount0Received, uint256 _amount1Received) = _sumAmountsForLiquidityRoundDown(_liquidityToRemove); diff --git a/test/harness/LPVaultHarness.sol b/test/harness/LPVaultHarness.sol index ca0fd89..2a69104 100644 --- a/test/harness/LPVaultHarness.sol +++ b/test/harness/LPVaultHarness.sol @@ -4,22 +4,27 @@ pragma solidity ^0.8.26; import {LPVault} from "src/LPVault.sol"; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; contract LPVaultHarness is LPVault { + using StateLibrary for IPoolManager; + constructor( IPoolManager _poolManager, PoolKey memory _targetPoolKey, LiquidityPosition[] memory _positions, string memory _name, - string memory _symbol - ) LPVault(_poolManager, _targetPoolKey, _positions, _name, _symbol) {} + string memory _symbol, + uint256 _initMaxValue + ) LPVault(_poolManager, _targetPoolKey, _positions, _name, _symbol, _initMaxValue) {} - function computeInitialLiquidity(uint256 _amount0Max, uint256 _amount1Max) + function computeInitialLiquidity() external view returns (uint128[] memory _liquidityPerPosition, uint128 _liquidity0) { - (_liquidityPerPosition, _liquidity0) = _calculateInitialLiquidity(_amount0Max, _amount1Max); + (uint160 _sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(targetPoolKey.toId()); + (_liquidityPerPosition, _liquidity0) = _calculateInitialLiquidity(_sqrtPriceX96); } function getAmountsForLiquidity( From f7db9669cc301e99c2610314dfbae7f351dec272 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Fri, 6 Feb 2026 11:49:45 -0500 Subject: [PATCH 12/16] Small refactor and fix failing test --- test/LPVault.t.sol | 30 +++++++++++++++++------------- test/YieldToken.unit.t.sol | 22 ++-------------------- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index af7955b..9cf7daa 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -7,6 +7,7 @@ import {Deployers} from "lib/v4-periphery/lib/v4-core/test/utils/Deployers.sol"; import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; import {LPVault} from "src/LPVault.sol"; import {LPVaultHarness} from "test/harness/LPVaultHarness.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {ImmutableState} from "v4-periphery/src/base/ImmutableState.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; import {Currency} from "v4-core/types/Currency.sol"; @@ -1370,17 +1371,17 @@ contract CollectFees is LPVaultTest { // Deposit and get shares (uint256 _shares,,) = _deposit(_depositor, _amount0, _amount1); - // Generate fees via swaps (but don't collect yet) - BalanceDelta _delta0 = _performSwap(true, _boundSwapAbs(_swapAbs0)); - BalanceDelta _delta1 = _performSwap(false, _boundSwapAbs(_swapAbs1)); - - uint256 _expectedFee0 = uint256(-int256(_delta0.amount0())) * DEFAULT_FEE / FEE_DENOMINATOR; - uint256 _expectedFee1 = uint256(-int256(_delta1.amount1())) * DEFAULT_FEE / FEE_DENOMINATOR; + // Generate fees via swaps (but don't collect yet). + _performSwap(true, _boundSwapAbs(_swapAbs0)); + _performSwap(false, _boundSwapAbs(_swapAbs1)); - // Transfer shares BEFORE collecting fees + // Transfer shares BEFORE collecting fees. This syncs fees and attributes accrued fees + // to the sender before balances move. vm.prank(_depositor); vault.transfer(_recipient, _shares); + (,, uint256 _expectedClaim0, uint256 _expectedClaim1) = vault.userFees(_depositor); + // Now collect fees - they should be attributed to depositor (original holder) vault.collectFees(); @@ -1388,8 +1389,8 @@ contract CollectFees is LPVaultTest { vm.prank(_depositor); (uint256 _claimed0, uint256 _claimed1) = vault.claim(_depositor); - assertApproxEqRel(_claimed0, _expectedFee0, FEE_TOLERANCE); - assertApproxEqRel(_claimed1, _expectedFee1, FEE_TOLERANCE); + assertEq(_claimed0, _expectedClaim0); + assertEq(_claimed1, _expectedClaim1); // Recipient should have no fees (they got shares after fees accrued) vm.prank(_recipient); @@ -1411,20 +1412,23 @@ contract CollectFees is LPVaultTest { // Note: This is a bit tricky since we need fees in the pool before our position exists // In practice, this tests that checkpoints are initialized correctly on first deposit _deposit(_depositor, _amount0, _amount1); + uint256 _balance = vault.balanceOf(_depositor); + uint256 _globalIndex0Before = vault.globalFeeIndex0Scaled(); // Now generate fees (vault has liquidity) - BalanceDelta _delta = _performSwap(true, _boundSwapAbs(_swapAbs0)); - uint256 _expectedFee0 = uint256(-int256(_delta.amount0())) * DEFAULT_FEE / FEE_DENOMINATOR; + _performSwap(true, _boundSwapAbs(_swapAbs0)); // Collect fees vault.collectFees(); + uint256 _globalIndex0After = vault.globalFeeIndex0Scaled(); + uint256 _expectedClaim0 = _expectedAccrued(_balance, _globalIndex0After - _globalIndex0Before); // Claim fees - should only get fees from after we deposited vm.prank(_depositor); (uint256 _claimed0,) = vault.claim(_depositor); - // Should have fees from the swap - assertApproxEqRel(_claimed0, _expectedFee0, FEE_TOLERANCE); + // Should only include fees accrued after the depositor held shares. + assertEq(_claimed0, _expectedClaim0); } /// @notice Tests that fee indexes update on transfer even without collecting fees. diff --git a/test/YieldToken.unit.t.sol b/test/YieldToken.unit.t.sol index 4f2bc5a..70f44c4 100644 --- a/test/YieldToken.unit.t.sol +++ b/test/YieldToken.unit.t.sol @@ -270,26 +270,8 @@ contract Burn is YieldTokenTest { _initialUserToken0Balance + _calculateAccruedFees(_params.mintAmount, _globalFeeIndex0, 0); uint256 _expectedToken1 = _initialUserToken1Balance + _calculateAccruedFees(_params.mintAmount, _globalFeeIndex1, 0); - // When user is the YT, mock transfers accrued fees (may round down due to fee index - // calculation) - if (_params.user == address(yieldToken)) { - // Allow 1 wei rounding error since fees flow through fee index calculation - assertApproxEqAbs( - token0.balanceOf(_params.user), - _initialUserToken0Balance + _params.collectedFeeToken0, - 1, - "YT as user: token0" - ); - assertApproxEqAbs( - token1.balanceOf(_params.user), - _initialUserToken1Balance + _params.collectedFeeToken1, - 1, - "YT as user: token1" - ); - } else { - assertEq(token0.balanceOf(_params.user), _expectedToken0); - assertEq(token1.balanceOf(_params.user), _expectedToken1); - } + assertEq(token0.balanceOf(_params.user), _expectedToken0); + assertEq(token1.balanceOf(_params.user), _expectedToken1); } } From efd4657c3bd4deabac14ca5300fdabf6ddfb5300 Mon Sep 17 00:00:00 2001 From: garyghayrat Date: Fri, 6 Feb 2026 12:00:46 -0500 Subject: [PATCH 13/16] Run scopelint fmt --- test/LPVault.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 9cf7daa..9e90b87 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -218,6 +218,7 @@ contract LPVaultTest is Test, Deployers { vm.prank(_depositor); (_shares, _amount0Used, _amount1Used) = vault.deposit(_amount0Max, _amount1Max, _liquidity); } + function _boundSwapAbs(uint256 _swapAbs) internal pure returns (int256) { return -int256(bound(_swapAbs, 1e18, 1e24)); } From bb8e24b963cd96f58e8fc3c99438da88fd5e2c03 Mon Sep 17 00:00:00 2001 From: keating Date: Tue, 24 Feb 2026 22:12:30 -0500 Subject: [PATCH 14/16] Minor improvements --- src/LPVault.sol | 27 ++++++++++++++------------- test/LPVault.t.sol | 4 ++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index e8f21c8..556b2e4 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -129,9 +129,6 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { /// @notice Thrown when attempting deposit before initialization. error LPVault__NotInitialized(); - /// @notice Thrown when the caller has insufficient token allowance for initialization. - error LPVault__InsufficientAllowance(); - /// @notice Thrown when INIT_MAX_VALUE constructor parameter is zero. error LPVault__ZeroValue(); @@ -242,15 +239,10 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { { if (liquidityInitialized) revert LPVault__AlreadyInitialized(); - // Check allowances before proceeding - if (IERC20(TOKEN0).allowance(msg.sender, address(this)) < _amount0Max) { - revert LPVault__InsufficientAllowance(); - } - if (IERC20(TOKEN1).allowance(msg.sender, address(this)) < _amount1Max) { - revert LPVault__InsufficientAllowance(); - } - // Fetch current pool price for value constraint check and liquidity calculation. + // NOTE: Price is read outside the unlock callback, so it may be stale by the time + // modifyLiquidity executes. The SlippageCheck in _addLiquidityToPositions provides + // the actual protection; this is a best-effort cap. (uint160 _sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(targetPoolKey.toId()); // Check value constraint: convert amount0 to token1 value and verify total <= INIT_MAX_VALUE. @@ -311,6 +303,9 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { if (!liquidityInitialized) revert LPVault__NotInitialized(); if (_liquidity == 0) revert LPVault__ZeroLiquidity(); + // Sync fee accounting using the current liquidity configuration before changing it. + _syncFees(); + // Calculate liquidity for each position based on current ratios uint128[] memory _liquidityPerPosition = new uint128[](positions.length); uint128 _position0Liquidity = positions[0].liquidity; @@ -385,7 +380,9 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { positions[_i].liquidity -= _liquidityToRemove[_i]; } - // If all liquidity is withdrawn, reset initialization to allow re-initialization. + // If all shares are burned, reset initialization to allow re-initialization. + // Using totalSupply (checked after burn below) would require reordering; position0.liquidity + // is equivalent since shares == position0.liquidity is a maintained invariant. if (positions[0].liquidity == 0) liquidityInitialized = false; // Burn shares before making external calls (reentrancy protection). @@ -474,13 +471,17 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { { DepositCallbackData memory _callbackData = abi.decode(_actionData, (DepositCallbackData)); - (int256 _totalDelta0, int256 _totalDelta1,,) = _addLiquidityToPositions( + (int256 _totalDelta0, int256 _totalDelta1, int256 _totalFeeDelta0, int256 _totalFeeDelta1) = _addLiquidityToPositions( _callbackData.liquidityPerPosition, _callbackData.amount0Max, _callbackData.amount1Max ); (uint256 _amount0Used, uint256 _amount1Used) = _settleDeltas(_totalDelta0, _totalDelta1, _callbackData.sender); + // Collect any fee tokens owed by the pool (should be zero on first init, but handles + // re-initialization after full withdrawal where fees may have accrued). + _collectFees(_totalFeeDelta0, _totalFeeDelta1); + return abi.encode(_amount0Used, _amount1Used); } diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 9e90b87..a3562c1 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; -import {Test, Vm} from "forge-std/Test.sol"; +import {Test, Vm, stdError} from "forge-std/Test.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Deployers} from "lib/v4-periphery/lib/v4-core/test/utils/Deployers.sol"; import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; @@ -501,7 +501,7 @@ contract InitializeLiquidity is LPVaultTest { deal(Currency.unwrap(currency0), initializer, _amount0Max); deal(Currency.unwrap(currency1), initializer, _amount1Max); - vm.expectRevert(LPVault.LPVault__InsufficientAllowance.selector); + vm.expectRevert(stdError.arithmeticError); vm.prank(initializer); vault.initializeLiquidity(_amount0Max, _amount1Max); } From a34cececf82330000de992bc0f30c580692dd846 Mon Sep 17 00:00:00 2001 From: keating Date: Tue, 24 Feb 2026 22:25:22 -0500 Subject: [PATCH 15/16] More small changes --- src/LPVault.sol | 40 +++++++--------------------------------- test/LPVault.t.sol | 6 ++++++ 2 files changed, 13 insertions(+), 33 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index 556b2e4..ce522e8 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -56,8 +56,7 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { enum CallbackAction { Deposit, Withdraw, - CollectFees, - InitializeLiquidity + CollectFees } /// @notice Struct for passing deposit callback data. @@ -258,7 +257,7 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { bytes memory _result = POOL_MANAGER.unlock( abi.encode( - CallbackAction.InitializeLiquidity, + CallbackAction.Deposit, abi.encode( DepositCallbackData({ sender: msg.sender, @@ -424,9 +423,6 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { if (_action == CallbackAction.Deposit) return _handleDepositCallback(_actionData); if (_action == CallbackAction.Withdraw) return _handleWithdrawCallback(_actionData); - if (_action == CallbackAction.InitializeLiquidity) { - return _handleInitializeLiquidityCallback(_actionData); - } return _handleCollectFeesCallback(); } @@ -455,31 +451,8 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { (uint256 _amount0Used, uint256 _amount1Used) = _settleDeltas(_totalDelta0, _totalDelta1, _callbackData.sender); - // Collect fees and update fee-per-share indexes. - _collectFees(_totalFeeDelta0, _totalFeeDelta1); - - return abi.encode(_amount0Used, _amount1Used); - } - - /// @notice Handles the initialize liquidity callback logic. - /// @param _actionData The encoded callback data. - /// @return The encoded result containing amounts used. - function _handleInitializeLiquidityCallback(bytes memory _actionData) - internal - virtual - returns (bytes memory) - { - DepositCallbackData memory _callbackData = abi.decode(_actionData, (DepositCallbackData)); - - (int256 _totalDelta0, int256 _totalDelta1, int256 _totalFeeDelta0, int256 _totalFeeDelta1) = _addLiquidityToPositions( - _callbackData.liquidityPerPosition, _callbackData.amount0Max, _callbackData.amount1Max - ); - - (uint256 _amount0Used, uint256 _amount1Used) = - _settleDeltas(_totalDelta0, _totalDelta1, _callbackData.sender); - - // Collect any fee tokens owed by the pool (should be zero on first init, but handles - // re-initialization after full withdrawal where fees may have accrued). + // Collect fee tokens from the pool. Fee-per-share indexes are updated separately + // by _syncFees() (called before unlock and in _update on mint/burn/transfer). _collectFees(_totalFeeDelta0, _totalFeeDelta1); return abi.encode(_amount0Used, _amount1Used); @@ -501,7 +474,8 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { (uint256 _amount0Received, uint256 _amount1Received) = _settleWithdrawDeltas(_totalDelta0, _totalDelta1, _callbackData.receiver); - // Collect fees and update fee-per-share indexes. + // Collect fee tokens from the pool. Fee-per-share indexes are updated separately + // by _syncFees() (called before unlock and in _update on mint/burn/transfer). _collectFees(_totalFeeDelta0, _totalFeeDelta1); return abi.encode(_amount0Received, _amount1Received); @@ -824,7 +798,7 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { } } - /// @notice Collects fee tokens from the pool and updates the fee-per-share indexes. + /// @notice Collects fee tokens from the pool. /// @dev Fee deltas are positive when the pool owes us fees. /// @param _feeDelta0 The fee delta for token0. /// @param _feeDelta1 The fee delta for token1. diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index a3562c1..1afeaa2 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -905,6 +905,12 @@ contract Withdraw is LPVaultTest { assertEq(vault.totalSupply(), _totalSupplyBefore); // Verify amounts received match total deposited (within rounding tolerance). + // Two sources of rounding loss compound across 2 deposits + 1 withdrawal: + // 1. v4 SqrtPriceMath asymmetric rounding: deposit rounds token amounts UP (user overpays), + // withdraw rounds DOWN (user receives less). Up to 1 wei per token per position per op. + // 2. FullMath.mulDiv truncation: proportional liquidity for positions 1 & 2 is truncated on + // each deposit/withdraw, so deposited vs withdrawn liquidity can differ by ~1 wei. + // With 2 positions contributing to each token, the practical max is ~5 wei. assertApproxEqAbs(_amount0Received, _amount0Used1, 5); assertApproxEqAbs(_amount1Received, _amount1Used1, 5); From 73154cf77b0281a4c6f6be08046743f9d028dd3f Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 25 Feb 2026 08:24:51 -0500 Subject: [PATCH 16/16] Fix linting issues --- src/LPVault.sol | 10 +++++----- test/LPVault.t.sol | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/LPVault.sol b/src/LPVault.sol index ce522e8..c7f90a5 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -835,12 +835,12 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { (uint256 _feeGrowthInside0NowX128, uint256 _feeGrowthInside1NowX128) = POOL_MANAGER.getFeeGrowthInside(_poolId, _position.tickLower, _position.tickUpper); - PositionCheckpoint storage _checkpoint = positionCheckpoints[_i]; + PositionCheckpoint storage checkpoint = positionCheckpoints[_i]; // Only compute fees if position has liquidity. if (_liquidity > 0) { - uint256 _feeGrowthInside0LastX128 = _checkpoint.feeGrowthInside0LastX128; - uint256 _feeGrowthInside1LastX128 = _checkpoint.feeGrowthInside1LastX128; + uint256 _feeGrowthInside0LastX128 = checkpoint.feeGrowthInside0LastX128; + uint256 _feeGrowthInside1LastX128 = checkpoint.feeGrowthInside1LastX128; // Compute growth deltas since last checkpoint. uint256 _deltaFeeGrowth0X128 = _feeGrowthInside0NowX128 - _feeGrowthInside0LastX128; @@ -855,8 +855,8 @@ contract LPVault is VaultShareToken, SafeCallback, DeltaResolver { } // Always advance checkpoints to prevent claiming fees from before we had liquidity. - _checkpoint.feeGrowthInside0LastX128 = _feeGrowthInside0NowX128; - _checkpoint.feeGrowthInside1LastX128 = _feeGrowthInside1NowX128; + checkpoint.feeGrowthInside0LastX128 = _feeGrowthInside0NowX128; + checkpoint.feeGrowthInside1LastX128 = _feeGrowthInside1NowX128; } // Update fee indexes if any fees accrued. diff --git a/test/LPVault.t.sol b/test/LPVault.t.sol index 1afeaa2..9f3f446 100644 --- a/test/LPVault.t.sol +++ b/test/LPVault.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; -import {Test, Vm, stdError} from "forge-std/Test.sol"; +import {Test, stdError} from "forge-std/Test.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Deployers} from "lib/v4-periphery/lib/v4-core/test/utils/Deployers.sol"; import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; @@ -678,7 +678,7 @@ contract Deposit is LPVaultTest { function test_RevertIf_NotInitialized(address _caller, uint128 _liquidity) public { // Deploy a fresh vault without initialization - LPVault freshVault = new LPVault( + LPVault _freshVault = new LPVault( manager, defaultPoolKey, defaultPositions, DEFAULT_NAME, DEFAULT_SYMBOL, INIT_MAX_VALUE ); @@ -687,11 +687,11 @@ contract Deposit is LPVaultTest { deal(Currency.unwrap(currency0), _caller, 1e18); deal(Currency.unwrap(currency1), _caller, 1e18); vm.startPrank(_caller); - MockERC20(Currency.unwrap(currency0)).approve(address(freshVault), 1e18); - MockERC20(Currency.unwrap(currency1)).approve(address(freshVault), 1e18); + MockERC20(Currency.unwrap(currency0)).approve(address(_freshVault), 1e18); + MockERC20(Currency.unwrap(currency1)).approve(address(_freshVault), 1e18); vm.expectRevert(LPVault.LPVault__NotInitialized.selector); - freshVault.deposit(1e18, 1e18, _liquidity); + _freshVault.deposit(1e18, 1e18, _liquidity); vm.stopPrank(); }