diff --git a/.forge-snapshots/PositionManager_collect.snap b/.forge-snapshots/PositionManager_collect.snap index ad535d66..e66f7944 100644 --- a/.forge-snapshots/PositionManager_collect.snap +++ b/.forge-snapshots/PositionManager_collect.snap @@ -1 +1 @@ -162386 \ No newline at end of file +162362 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect_native.snap b/.forge-snapshots/PositionManager_collect_native.snap new file mode 100644 index 00000000..9b3ea362 --- /dev/null +++ b/.forge-snapshots/PositionManager_collect_native.snap @@ -0,0 +1 @@ +153577 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect_sameRange.snap b/.forge-snapshots/PositionManager_collect_sameRange.snap index ad535d66..e66f7944 100644 --- a/.forge-snapshots/PositionManager_collect_sameRange.snap +++ b/.forge-snapshots/PositionManager_collect_sameRange.snap @@ -1 +1 @@ -162386 \ No newline at end of file +162362 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decreaseLiquidity.snap b/.forge-snapshots/PositionManager_decreaseLiquidity.snap index 4c00c495..0bf016cd 100644 --- a/.forge-snapshots/PositionManager_decreaseLiquidity.snap +++ b/.forge-snapshots/PositionManager_decreaseLiquidity.snap @@ -1 +1 @@ -127764 \ No newline at end of file +127740 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap b/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap new file mode 100644 index 00000000..0bb776a3 --- /dev/null +++ b/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap @@ -0,0 +1 @@ +119120 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap b/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap index 829d911f..36c9a4e9 100644 --- a/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap +++ b/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap @@ -1 +1 @@ -140645 \ No newline at end of file +140621 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increaseLiquidity_erc20.snap b/.forge-snapshots/PositionManager_increaseLiquidity_erc20.snap index 47d4166c..0e6c2b08 100644 --- a/.forge-snapshots/PositionManager_increaseLiquidity_erc20.snap +++ b/.forge-snapshots/PositionManager_increaseLiquidity_erc20.snap @@ -1 +1 @@ -157182 \ No newline at end of file +157204 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increaseLiquidity_native.snap b/.forge-snapshots/PositionManager_increaseLiquidity_native.snap new file mode 100644 index 00000000..ff08bd90 --- /dev/null +++ b/.forge-snapshots/PositionManager_increaseLiquidity_native.snap @@ -0,0 +1 @@ +142500 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap b/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap index 41f6d2f0..bec5766b 100644 --- a/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap +++ b/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap @@ -1 +1 @@ -148692 \ No newline at end of file +148668 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap b/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap index 8dd57a0a..f458b0f6 100644 --- a/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap +++ b/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap @@ -1 +1 @@ -184956 \ No newline at end of file +184932 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint.snap b/.forge-snapshots/PositionManager_mint.snap index f0022990..78f5fe5a 100644 --- a/.forge-snapshots/PositionManager_mint.snap +++ b/.forge-snapshots/PositionManager_mint.snap @@ -1 +1 @@ -418192 \ No newline at end of file +418214 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_native.snap b/.forge-snapshots/PositionManager_mint_native.snap new file mode 100644 index 00000000..5158987a --- /dev/null +++ b/.forge-snapshots/PositionManager_mint_native.snap @@ -0,0 +1 @@ +366282 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_nativeWithSweep.snap b/.forge-snapshots/PositionManager_mint_nativeWithSweep.snap new file mode 100644 index 00000000..cdbe728c --- /dev/null +++ b/.forge-snapshots/PositionManager_mint_nativeWithSweep.snap @@ -0,0 +1 @@ +373221 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_onSameTickLower.snap b/.forge-snapshots/PositionManager_mint_onSameTickLower.snap index 6be7a189..10adc4b7 100644 --- a/.forge-snapshots/PositionManager_mint_onSameTickLower.snap +++ b/.forge-snapshots/PositionManager_mint_onSameTickLower.snap @@ -1 +1 @@ -360874 \ No newline at end of file +360896 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap b/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap index c3f898bc..4ee8c2a3 100644 --- a/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap +++ b/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap @@ -1 +1 @@ -361516 \ No newline at end of file +361538 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_sameRange.snap b/.forge-snapshots/PositionManager_mint_sameRange.snap index 6ba2ef01..bda37a64 100644 --- a/.forge-snapshots/PositionManager_mint_sameRange.snap +++ b/.forge-snapshots/PositionManager_mint_sameRange.snap @@ -1 +1 @@ -287098 \ No newline at end of file +287120 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap b/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap index 86a7d8c2..5cc7af99 100644 --- a/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap +++ b/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap @@ -1 +1 @@ -366892 \ No newline at end of file +366914 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_multicall_initialize_mint.snap b/.forge-snapshots/PositionManager_multicall_initialize_mint.snap index 59036d4e..33a02afb 100644 --- a/.forge-snapshots/PositionManager_multicall_initialize_mint.snap +++ b/.forge-snapshots/PositionManager_multicall_initialize_mint.snap @@ -1 +1 @@ -462111 \ No newline at end of file +462133 \ No newline at end of file diff --git a/src/PositionManager.sol b/src/PositionManager.sol index c42e6558..e76c65f8 100644 --- a/src/PositionManager.sol +++ b/src/PositionManager.sol @@ -46,6 +46,7 @@ contract PositionManager is IPositionManager, ERC721Permit, PoolInitializer, Mul /// @return returnData is the endocing of each actions return information function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external + payable checkDeadline(deadline) returns (bytes[] memory) { @@ -155,6 +156,9 @@ contract PositionManager is IPositionManager, ERC721Permit, PoolInitializer, Mul // the sender is the payer or receiver if (currencyDelta < 0) { currency.settle(poolManager, sender, uint256(-int256(currencyDelta)), false); + + // if there are native tokens left over after settling, return to sender + if (currency.isNative()) _sweepNativeToken(sender); } else if (currencyDelta > 0) { currency.take(poolManager, sender, uint256(int256(currencyDelta)), false); } @@ -189,6 +193,13 @@ contract PositionManager is IPositionManager, ERC721Permit, PoolInitializer, Mul ); } + /// @dev Send excess native tokens back to the recipient (sender) + /// @param recipient the receiver of the excess native tokens. Should be the caller, the one that sent the native tokens + function _sweepNativeToken(address recipient) internal { + uint256 nativeBalance = address(this).balance; + if (nativeBalance > 0) address(recipient).call{value: nativeBalance}(""); + } + // ensures liquidity of the position is empty before burning the token. function _validateBurn(uint256 tokenId) internal view { bytes32 positionId = getPositionIdFromTokenId(tokenId); diff --git a/src/interfaces/IPositionManager.sol b/src/interfaces/IPositionManager.sol index c47929a4..362615f4 100644 --- a/src/interfaces/IPositionManager.sol +++ b/src/interfaces/IPositionManager.sol @@ -29,7 +29,7 @@ interface IPositionManager { /// @param payload is an encoding of actions, and parameters for those actions /// @param deadline is the deadline for the batched actions to be executed /// @return returnData is the endocing of each actions return information - function modifyLiquidities(bytes calldata payload, uint256 deadline) external returns (bytes[] memory); + function modifyLiquidities(bytes calldata payload, uint256 deadline) external payable returns (bytes[] memory); function nextTokenId() external view returns (uint256); } diff --git a/test/position-managers/Gas.t.sol b/test/position-managers/Gas.t.sol index df267aec..def245ce 100644 --- a/test/position-managers/Gas.t.sol +++ b/test/position-managers/Gas.t.sol @@ -39,6 +39,7 @@ contract GasTest is Test, PosmTestSetup, GasSnapshot { uint256 FEE_WAD; LiquidityRange range; + LiquidityRange nativeRange; function setUp() public { (alice, alicePK) = makeAddrAndKey("ALICE"); @@ -48,6 +49,7 @@ contract GasTest is Test, PosmTestSetup, GasSnapshot { deployMintAndApprove2Currencies(); (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + (nativeKey,) = initPool(CurrencyLibrary.NATIVE, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); FEE_WAD = uint256(key.fee).mulDivDown(FixedPointMathLib.WAD, 1_000_000); // Requires currency0 and currency1 to be set in base Deployers contract. @@ -63,6 +65,7 @@ contract GasTest is Test, PosmTestSetup, GasSnapshot { // define a reusable range range = LiquidityRange({poolKey: key, tickLower: -300, tickUpper: 300}); + nativeRange = LiquidityRange({poolKey: nativeKey, tickLower: -300, tickUpper: 300}); } function test_gas_mint() public { @@ -317,4 +320,72 @@ contract GasTest is Test, PosmTestSetup, GasSnapshot { function test_gas_burn() public {} function test_gas_burnEmpty() public {} + + // Native Token Gas Tests + function test_gas_mint_native() public { + uint256 liquidityToAdd = 10_000 ether; + bytes memory calls = getMintEncoded(nativeRange, liquidityToAdd, address(this), ZERO_BYTES); + + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(nativeRange.tickLower), + TickMath.getSqrtPriceAtTick(nativeRange.tickUpper), + uint128(liquidityToAdd) + ); + lpm.modifyLiquidities{value: amount0 + 1}(calls, _deadline); + snapLastCall("PositionManager_mint_native"); + } + + function test_gas_mint_native_excess() public { + uint256 liquidityToAdd = 10_000 ether; + bytes memory calls = getMintEncoded(nativeRange, liquidityToAdd, address(this), ZERO_BYTES); + + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(nativeRange.tickLower), + TickMath.getSqrtPriceAtTick(nativeRange.tickUpper), + uint128(liquidityToAdd) + ); + // overpay on the native token + lpm.modifyLiquidities{value: amount0 * 2}(calls, _deadline); + snapLastCall("PositionManager_mint_nativeWithSweep"); + } + + function test_gas_increase_native() public { + uint256 tokenId = lpm.nextTokenId(); + mintWithNative(SQRT_PRICE_1_1, nativeRange, 10_000 ether, address(this), ZERO_BYTES); + + uint256 liquidityToAdd = 10_000 ether; + bytes memory calls = getIncreaseEncoded(tokenId, liquidityToAdd, ZERO_BYTES); + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(nativeRange.tickLower), + TickMath.getSqrtPriceAtTick(nativeRange.tickUpper), + uint128(liquidityToAdd) + ); + lpm.modifyLiquidities{value: amount0 + 1}(calls, _deadline); + snapLastCall("PositionManager_increaseLiquidity_native"); + } + + function test_gas_decrease_native() public { + uint256 tokenId = lpm.nextTokenId(); + mintWithNative(SQRT_PRICE_1_1, nativeRange, 10_000 ether, address(this), ZERO_BYTES); + + uint256 liquidityToRemove = 10_000 ether; + bytes memory calls = getDecreaseEncoded(tokenId, liquidityToRemove, ZERO_BYTES); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_decreaseLiquidity_native"); + } + + function test_gas_collect_native() public { + uint256 tokenId = lpm.nextTokenId(); + mintWithNative(SQRT_PRICE_1_1, nativeRange, 10_000 ether, address(this), ZERO_BYTES); + + // donate to create fee revenue + donateRouter.donate{value: 0.2e18}(nativeRange.poolKey, 0.2e18, 0.2e18, ZERO_BYTES); + + bytes memory calls = getCollectEncoded(tokenId, ZERO_BYTES); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_collect_native"); + } } diff --git a/test/position-managers/NativeToken.t.sol b/test/position-managers/NativeToken.t.sol new file mode 100644 index 00000000..ffd1a18b --- /dev/null +++ b/test/position-managers/NativeToken.t.sol @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {Constants} from "@uniswap/v4-core/test/utils/Constants.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol"; + +import {IPositionManager, Actions} from "../../src/interfaces/IPositionManager.sol"; +import {PositionManager} from "../../src/PositionManager.sol"; +import {LiquidityRange, LiquidityRangeId, LiquidityRangeIdLibrary} from "../../src/types/LiquidityRange.sol"; + +import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; +import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; +import {Planner} from "../utils/Planner.sol"; + +contract PositionManagerTest is Test, PosmTestSetup, LiquidityFuzzers { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using LiquidityRangeIdLibrary for LiquidityRange; + using Planner for Planner.Plan; + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + using SafeCast for *; + + PoolId poolId; + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + currency0 = CurrencyLibrary.NATIVE; + (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + + lpm = new PositionManager(manager); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + + vm.deal(address(this), type(uint256).max); + } + + function test_fuzz_mint_native(IPoolManager.ModifyLiquidityParams memory params) public { + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // two-sided liquidity + + uint256 liquidityToAdd = + params.liquidityDelta < 0 ? uint256(-params.liquidityDelta) : uint256(params.liquidityDelta); + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + + uint256 tokenId = lpm.nextTokenId(); + bytes memory calls = getMintEncoded(range, liquidityToAdd, address(this), ZERO_BYTES); + + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(params.tickLower), + TickMath.getSqrtPriceAtTick(params.tickUpper), + liquidityToAdd.toUint128() + ); + // add extra wei because modifyLiquidities may be rounding up, LiquidityAmounts is imprecise? + bytes[] memory result = lpm.modifyLiquidities{value: amount0 + 1}(calls, _deadline); + BalanceDelta delta = abi.decode(result[0], (BalanceDelta)); + + bytes32 positionId = + keccak256(abi.encodePacked(address(lpm), range.tickLower, range.tickUpper, bytes32(tokenId))); + (uint256 liquidity,,) = manager.getPositionInfo(range.poolKey.toId(), positionId); + + assertEq(liquidity, uint256(params.liquidityDelta)); + assertEq(balance0Before - currency0.balanceOfSelf(), uint256(int256(-delta.amount0())), "incorrect amount0"); + assertEq(balance1Before - currency1.balanceOfSelf(), uint256(int256(-delta.amount1())), "incorrect amount1"); + } + + // minting with excess native tokens are returned to caller + function test_fuzz_mint_native_excess(IPoolManager.ModifyLiquidityParams memory params) public { + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // two-sided liquidity + + uint256 liquidityToAdd = + params.liquidityDelta < 0 ? uint256(-params.liquidityDelta) : uint256(params.liquidityDelta); + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + + uint256 tokenId = lpm.nextTokenId(); + bytes memory calls = getMintEncoded(range, liquidityToAdd, address(this), ZERO_BYTES); + + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(params.tickLower), + TickMath.getSqrtPriceAtTick(params.tickUpper), + liquidityToAdd.toUint128() + ); + + // Mint with excess native tokens + bytes[] memory result = lpm.modifyLiquidities{value: amount0 * 2 + 1}(calls, _deadline); + BalanceDelta delta = abi.decode(result[0], (BalanceDelta)); + + bytes32 positionId = + keccak256(abi.encodePacked(address(lpm), range.tickLower, range.tickUpper, bytes32(tokenId))); + (uint256 liquidity,,) = manager.getPositionInfo(range.poolKey.toId(), positionId); + assertEq(liquidity, uint256(params.liquidityDelta)); + + // only paid the delta amount, with excess tokens returned to caller + assertEq(balance0Before - currency0.balanceOfSelf(), uint256(int256(-delta.amount0()))); + assertEq(balance0Before - currency0.balanceOfSelf(), amount0 + 1); // TODO: off by one?? + assertEq(balance1Before - currency1.balanceOfSelf(), uint256(int256(-delta.amount1()))); + } + + function test_fuzz_burn_native(IPoolManager.ModifyLiquidityParams memory params) public { + uint256 balance0Start = address(this).balance; + uint256 balance1Start = currency1.balanceOfSelf(); + + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // two-sided liquidity + + uint256 liquidityToAdd = + params.liquidityDelta < 0 ? uint256(-params.liquidityDelta) : uint256(params.liquidityDelta); + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + uint256 tokenId = lpm.nextTokenId(); + mintWithNative(SQRT_PRICE_1_1, range, liquidityToAdd, address(this), ZERO_BYTES); + + bytes32 positionId = + keccak256(abi.encodePacked(address(lpm), range.tickLower, range.tickUpper, bytes32(tokenId))); + (uint256 liquidity,,) = manager.getPositionInfo(range.poolKey.toId(), positionId); + assertEq(liquidity, uint256(params.liquidityDelta)); + + // burn liquidity + uint256 balance0BeforeBurn = currency0.balanceOfSelf(); + uint256 balance1BeforeBurn = currency1.balanceOfSelf(); + + BalanceDelta deltaDecrease = decreaseLiquidity(tokenId, liquidity, ZERO_BYTES); + burn(tokenId); + + (liquidity,,) = manager.getPositionInfo(range.poolKey.toId(), positionId); + assertEq(liquidity, 0); + + // TODO: slightly off by 1 bip (0.0001%) + assertApproxEqRel( + currency0.balanceOfSelf(), balance0BeforeBurn + uint256(uint128(deltaDecrease.amount0())), 0.0001e18 + ); + assertApproxEqRel( + currency1.balanceOfSelf(), balance1BeforeBurn + uint256(uint128(deltaDecrease.amount1())), 0.0001e18 + ); + + // OZ 721 will revert if the token does not exist + vm.expectRevert(); + lpm.ownerOf(1); + + // no tokens were lost, TODO: fuzzer showing off by 1 sometimes + assertApproxEqAbs(currency0.balanceOfSelf(), balance0Start, 1 wei); + assertApproxEqAbs(address(this).balance, balance0Start, 1 wei); + assertApproxEqAbs(currency1.balanceOfSelf(), balance1Start, 1 wei); + } + + function test_fuzz_increaseLiquidity_native(IPoolManager.ModifyLiquidityParams memory params) public { + // fuzz for the range + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + vm.assume(params.tickLower < -60 && 60 < params.tickUpper); // two-sided liquidity + + // TODO: figure out if we can fuzz the increase liquidity delta. we're annoyingly getting TickLiquidityOverflow + uint256 liquidityToAdd = 1e18; + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + // mint the position with native token liquidity + uint256 tokenId = lpm.nextTokenId(); + mintWithNative(SQRT_PRICE_1_1, range, liquidityToAdd, address(this), ZERO_BYTES); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = currency1.balanceOfSelf(); + + // calculate how much native token is required for the liquidity increase (doubling the liquidity) + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(params.tickLower), + TickMath.getSqrtPriceAtTick(params.tickUpper), + uint128(liquidityToAdd) + ); + + bytes memory calls = getIncreaseEncoded(tokenId, liquidityToAdd, ZERO_BYTES); // double the liquidity + bytes[] memory result = lpm.modifyLiquidities{value: amount0 + 1 wei}(calls, _deadline); // TODO: off by one wei + BalanceDelta delta = abi.decode(result[0], (BalanceDelta)); + + // verify position liquidity increased + bytes32 positionId = + keccak256(abi.encodePacked(address(lpm), range.tickLower, range.tickUpper, bytes32(tokenId))); + (uint256 liquidity,,) = manager.getPositionInfo(range.poolKey.toId(), positionId); + assertEq(liquidity, liquidityToAdd + liquidityToAdd); // liquidity was doubled + + // verify native token balances changed as expected + assertEq(balance0Before - currency0.balanceOfSelf(), amount0 + 1 wei); + assertEq(balance0Before - currency0.balanceOfSelf(), uint256(int256(-delta.amount0()))); + assertEq(balance1Before - currency1.balanceOfSelf(), uint256(int256(-delta.amount1()))); + } + + // overpaying native tokens on increase liquidity is returned to caller + function test_fuzz_increaseLiquidity_native_excess(IPoolManager.ModifyLiquidityParams memory params) public { + // fuzz for the range + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // two-sided liquidity + + // TODO: figure out if we can fuzz the increase liquidity delta. we're annoyingly getting TickLiquidityOverflow + uint256 liquidityToAdd = 1e18; + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + // mint the position with native token liquidity + uint256 tokenId = lpm.nextTokenId(); + mintWithNative(SQRT_PRICE_1_1, range, liquidityToAdd, address(this), ZERO_BYTES); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = currency1.balanceOfSelf(); + + // calculate how much native token is required for the liquidity increase (doubling the liquidity) + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(params.tickLower), + TickMath.getSqrtPriceAtTick(params.tickUpper), + uint128(liquidityToAdd) + ); + + bytes memory calls = getIncreaseEncoded(tokenId, liquidityToAdd, ZERO_BYTES); // double the liquidity + bytes[] memory result = lpm.modifyLiquidities{value: amount0 * 2}(calls, _deadline); // overpay on increase liquidity + BalanceDelta delta = abi.decode(result[0], (BalanceDelta)); + + // verify position liquidity increased + bytes32 positionId = + keccak256(abi.encodePacked(address(lpm), range.tickLower, range.tickUpper, bytes32(tokenId))); + (uint256 liquidity,,) = manager.getPositionInfo(range.poolKey.toId(), positionId); + assertEq(liquidity, liquidityToAdd + liquidityToAdd); // liquidity was doubled + + // verify native token balances changed as expected, with overpaid tokens returned + assertEq(balance0Before - currency0.balanceOfSelf(), amount0 + 1 wei); + assertEq(balance0Before - currency0.balanceOfSelf(), uint256(int256(-delta.amount0()))); + assertEq(balance1Before - currency1.balanceOfSelf(), uint256(int256(-delta.amount1()))); + } + + function test_fuzz_decreaseLiquidity_native( + IPoolManager.ModifyLiquidityParams memory params, + uint256 decreaseLiquidityDelta + ) public { + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // two-sided liquidity + decreaseLiquidityDelta = bound(decreaseLiquidityDelta, 1, uint256(params.liquidityDelta)); + + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + // mint the position with native token liquidity + uint256 tokenId = lpm.nextTokenId(); + mintWithNative(SQRT_PRICE_1_1, range, uint256(params.liquidityDelta), address(this), ZERO_BYTES); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = currency1.balanceOfSelf(); + + // decrease liquidity and receive native tokens + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(params.tickLower), + TickMath.getSqrtPriceAtTick(params.tickUpper), + uint128(decreaseLiquidityDelta) + ); + BalanceDelta delta = decreaseLiquidity(tokenId, decreaseLiquidityDelta, ZERO_BYTES); + + bytes32 positionId = + keccak256(abi.encodePacked(address(lpm), range.tickLower, range.tickUpper, bytes32(tokenId))); + (uint256 liquidity,,) = manager.getPositionInfo(range.poolKey.toId(), positionId); + assertEq(liquidity, uint256(params.liquidityDelta) - decreaseLiquidityDelta); + + // verify native token balances changed as expected + assertApproxEqAbs(currency0.balanceOfSelf() - balance0Before, amount0, 1 wei); + assertEq(currency0.balanceOfSelf() - balance0Before, uint128(delta.amount0())); + assertEq(currency1.balanceOfSelf() - balance1Before, uint128(delta.amount1())); + } + + function test_fuzz_collect_native(IPoolManager.ModifyLiquidityParams memory params) public { + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // two-sided liquidity + + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + // mint the position with native token liquidity + uint256 tokenId = lpm.nextTokenId(); + mintWithNative(SQRT_PRICE_1_1, range, uint256(params.liquidityDelta), address(this), ZERO_BYTES); + + // donate to generate fee revenue + uint256 feeRevenue0 = 1e18; + uint256 feeRevenue1 = 0.1e18; + donateRouter.donate{value: 1e18}(key, feeRevenue0, feeRevenue1, ZERO_BYTES); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = currency1.balanceOfSelf(); + BalanceDelta delta = collect(tokenId, ZERO_BYTES); + + assertApproxEqAbs(currency0.balanceOfSelf() - balance0Before, feeRevenue0, 1 wei); // TODO: fuzzer off by 1 wei + assertEq(currency0.balanceOfSelf() - balance0Before, uint128(delta.amount0())); + assertEq(currency1.balanceOfSelf() - balance1Before, uint128(delta.amount1())); + } +} diff --git a/test/shared/LiquidityOperations.sol b/test/shared/LiquidityOperations.sol index caec186e..7202e696 100644 --- a/test/shared/LiquidityOperations.sol +++ b/test/shared/LiquidityOperations.sol @@ -5,6 +5,9 @@ import {CommonBase} from "forge-std/Base.sol"; import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; import {PositionManager, Actions} from "../../src/PositionManager.sol"; import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; @@ -12,6 +15,7 @@ import {Planner} from "../utils/Planner.sol"; abstract contract LiquidityOperations is CommonBase { using Planner for Planner.Plan; + using SafeCast for *; PositionManager lpm; @@ -21,14 +25,32 @@ abstract contract LiquidityOperations is CommonBase { internal returns (BalanceDelta) { - Planner.Plan memory planner = Planner.init(); - planner = planner.add(Actions.MINT, abi.encode(_range, liquidity, recipient, hookData)); - - bytes memory calls = planner.finalize(_range.poolKey); + bytes memory calls = getMintEncoded(_range, liquidity, recipient, hookData); bytes[] memory result = lpm.modifyLiquidities(calls, _deadline); return abi.decode(result[0], (BalanceDelta)); } + function mintWithNative( + uint160 sqrtPriceX96, + LiquidityRange memory _range, + uint256 liquidity, + address recipient, + bytes memory hookData + ) internal returns (BalanceDelta) { + // determine the amount of ETH to send on-mint + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(_range.tickLower), + TickMath.getSqrtPriceAtTick(_range.tickUpper), + liquidity.toUint128() + ); + bytes memory calls = getMintEncoded(_range, liquidity, recipient, hookData); + // add extra wei because modifyLiquidities may be rounding up, LiquidityAmounts is imprecise? + bytes[] memory result = lpm.modifyLiquidities{value: amount0 + 1}(calls, _deadline); + + return abi.decode(result[0], (BalanceDelta)); + } + function increaseLiquidity(uint256 tokenId, uint256 liquidityToAdd, bytes memory hookData) internal returns (BalanceDelta) @@ -63,6 +85,17 @@ abstract contract LiquidityOperations is CommonBase { } // Helper functions for getting encoded calldata for .modifyLiquidities + function getMintEncoded(LiquidityRange memory _range, uint256 liquidity, address recipient, bytes memory hookData) + internal + view + returns (bytes memory) + { + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.MINT, abi.encode(_range, liquidity, recipient, hookData)); + + return planner.finalize(_range.poolKey); + } + function getIncreaseEncoded(uint256 tokenId, uint256 liquidityToAdd, bytes memory hookData) internal view