diff --git a/script/DeployPosm.s.sol b/script/DeployPosm.s.sol index 5bbb6184..e3a20758 100644 --- a/script/DeployPosm.s.sol +++ b/script/DeployPosm.s.sol @@ -10,6 +10,7 @@ import {PositionManager} from "../src/PositionManager.sol"; import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; import {IPositionDescriptor} from "../src/interfaces/IPositionDescriptor.sol"; import {PositionDescriptor} from "../src/PositionDescriptor.sol"; +import {IWETH9} from "../src/interfaces/external/IWETH9.sol"; contract DeployPosmTest is Script { function setUp() public {} @@ -30,7 +31,8 @@ contract DeployPosmTest is Script { IPoolManager(poolManager), IAllowanceTransfer(permit2), unsubscribeGasLimit, - IPositionDescriptor(address(positionDescriptor)) + IPositionDescriptor(address(positionDescriptor)), + IWETH9(wrappedNative) ); console2.log("PositionManager", address(posm)); diff --git a/snapshots/BaseActionsRouterTest.json b/snapshots/BaseActionsRouterTest.json index ce832601..1a9c9216 100644 --- a/snapshots/BaseActionsRouterTest.json +++ b/snapshots/BaseActionsRouterTest.json @@ -1,3 +1,3 @@ { - "BaseActionsRouter_mock10commands": "60677" + "BaseActionsRouter_mock10commands": "33725" } \ No newline at end of file diff --git a/snapshots/PaymentsTests.json b/snapshots/PaymentsTests.json index e8fdbdeb..9a12dc61 100644 --- a/snapshots/PaymentsTests.json +++ b/snapshots/PaymentsTests.json @@ -1,6 +1,6 @@ { - "Payments_swap_settleFromCaller_takeAllToMsgSender": "129642", - "Payments_swap_settleFromCaller_takeAllToSpecifiedAddress": "131705", - "Payments_swap_settleWithBalance_takeAllToMsgSender": "123910", - "Payments_swap_settleWithBalance_takeAllToSpecifiedAddress": "124052" + "Payments_swap_settleFromCaller_takeAllToMsgSender": "104210", + "Payments_swap_settleFromCaller_takeAllToSpecifiedAddress": "104961", + "Payments_swap_settleWithBalance_takeAllToMsgSender": "95138", + "Payments_swap_settleWithBalance_takeAllToSpecifiedAddress": "95052" } \ No newline at end of file diff --git a/snapshots/PosMGasTest.json b/snapshots/PosMGasTest.json index 363c9a45..d513369d 100644 --- a/snapshots/PosMGasTest.json +++ b/snapshots/PosMGasTest.json @@ -1,41 +1,41 @@ { - "PositionManager_burn_empty": "50446", - "PositionManager_burn_empty_native": "50446", - "PositionManager_burn_nonEmpty_native_withClose": "125624", - "PositionManager_burn_nonEmpty_native_withTakePair": "125106", - "PositionManager_burn_nonEmpty_withClose": "132486", - "PositionManager_burn_nonEmpty_withTakePair": "131968", - "PositionManager_collect_native": "146344", - "PositionManager_collect_sameRange": "154922", - "PositionManager_collect_withClose": "154922", - "PositionManager_collect_withTakePair": "154287", - "PositionManager_decreaseLiquidity_native": "112020", - "PositionManager_decreaseLiquidity_withClose": "119803", - "PositionManager_decreaseLiquidity_withTakePair": "119168", - "PositionManager_decrease_burnEmpty": "135283", - "PositionManager_decrease_burnEmpty_native": "128420", - "PositionManager_decrease_sameRange_allLiquidity": "132490", - "PositionManager_decrease_take_take": "120423", - "PositionManager_increaseLiquidity_erc20_withClose": "159083", - "PositionManager_increaseLiquidity_erc20_withSettlePair": "158035", - "PositionManager_increaseLiquidity_native": "140898", - "PositionManager_increase_autocompoundExactUnclaimedFees": "136359", - "PositionManager_increase_autocompoundExcessFeesCredit": "177414", - "PositionManager_increase_autocompound_clearExcess": "148040", - "PositionManager_mint_native": "364771", - "PositionManager_mint_nativeWithSweep_withClose": "373294", - "PositionManager_mint_nativeWithSweep_withSettlePair": "372529", - "PositionManager_mint_onSameTickLower": "317643", - "PositionManager_mint_onSameTickUpper": "318313", - "PositionManager_mint_sameRange": "243882", - "PositionManager_mint_settleWithBalance_sweep": "419098", - "PositionManager_mint_warmedPool_differentRange": "323674", - "PositionManager_mint_withClose": "420196", - "PositionManager_mint_withSettlePair": "419266", - "PositionManager_multicall_initialize_mint": "456001", - "PositionManager_permit": "79076", - "PositionManager_permit_secondPosition": "61976", - "PositionManager_permit_twice": "44852", - "PositionManager_subscribe": "84348", - "PositionManager_unsubscribe": "59238" + "PositionManager_burn_empty": "15061", + "PositionManager_burn_empty_native": "15061", + "PositionManager_burn_nonEmpty_native_withClose": "51029", + "PositionManager_burn_nonEmpty_native_withTakePair": "50330", + "PositionManager_burn_nonEmpty_withClose": "47267", + "PositionManager_burn_nonEmpty_withTakePair": "46568", + "PositionManager_collect_native": "77416", + "PositionManager_collect_sameRange": "73654", + "PositionManager_collect_withClose": "73654", + "PositionManager_collect_withTakePair": "72955", + "PositionManager_decreaseLiquidity_native": "44201", + "PositionManager_decreaseLiquidity_withClose": "40439", + "PositionManager_decreaseLiquidity_withTakePair": "39740", + "PositionManager_decrease_burnEmpty": "49571", + "PositionManager_decrease_burnEmpty_native": "53333", + "PositionManager_decrease_sameRange_allLiquidity": "38726", + "PositionManager_decrease_take_take": "40547", + "PositionManager_increaseLiquidity_erc20_withClose": "49647", + "PositionManager_increaseLiquidity_erc20_withSettlePair": "48903", + "PositionManager_increaseLiquidity_native": "47802", + "PositionManager_increase_autocompoundExactUnclaimedFees": "62979", + "PositionManager_increase_autocompoundExcessFeesCredit": "78466", + "PositionManager_increase_autocompound_clearExcess": "72892", + "PositionManager_mint_native": "338359", + "PositionManager_mint_nativeWithSweep_withClose": "346306", + "PositionManager_mint_nativeWithSweep_withSettlePair": "345389", + "PositionManager_mint_onSameTickLower": "256851", + "PositionManager_mint_onSameTickUpper": "261521", + "PositionManager_mint_sameRange": "162190", + "PositionManager_mint_settleWithBalance_sweep": "384846", + "PositionManager_mint_warmedPool_differentRange": "262082", + "PositionManager_mint_withClose": "393304", + "PositionManager_mint_withSettlePair": "392474", + "PositionManager_multicall_initialize_mint": "426688", + "PositionManager_permit": "53780", + "PositionManager_permit_secondPosition": "29380", + "PositionManager_permit_twice": "7480", + "PositionManager_subscribe": "55708", + "PositionManager_unsubscribe": "26756" } \ No newline at end of file diff --git a/snapshots/QuoterTest.json b/snapshots/QuoterTest.json index 53525acd..21aecf76 100644 --- a/snapshots/QuoterTest.json +++ b/snapshots/QuoterTest.json @@ -1,15 +1,15 @@ { - "Quoter_exactInputSingle_oneForZero_multiplePositions": "143930", - "Quoter_exactInputSingle_zeroForOne_multiplePositions": "149382", - "Quoter_exactOutputSingle_oneForZero": "78203", - "Quoter_exactOutputSingle_zeroForOne": "82626", - "Quoter_quoteExactInput_oneHop_1TickLoaded": "120491", - "Quoter_quoteExactInput_oneHop_initializedAfter": "145414", - "Quoter_quoteExactInput_oneHop_startingInitialized": "79437", - "Quoter_quoteExactInput_twoHops": "201179", - "Quoter_quoteExactOutput_oneHop_1TickLoaded": "119782", - "Quoter_quoteExactOutput_oneHop_2TicksLoaded": "149919", - "Quoter_quoteExactOutput_oneHop_initializedAfter": "119850", - "Quoter_quoteExactOutput_oneHop_startingInitialized": "96549", - "Quoter_quoteExactOutput_twoHops": "200630" + "Quoter_exactInputSingle_oneForZero_multiplePositions": "121454", + "Quoter_exactInputSingle_zeroForOne_multiplePositions": "126894", + "Quoter_exactOutputSingle_oneForZero": "55727", + "Quoter_exactOutputSingle_zeroForOne": "60138", + "Quoter_quoteExactInput_oneHop_1TickLoaded": "97723", + "Quoter_quoteExactInput_oneHop_initializedAfter": "122646", + "Quoter_quoteExactInput_oneHop_startingInitialized": "46181", + "Quoter_quoteExactInput_twoHops": "177431", + "Quoter_quoteExactOutput_oneHop_1TickLoaded": "97014", + "Quoter_quoteExactOutput_oneHop_2TicksLoaded": "127151", + "Quoter_quoteExactOutput_oneHop_initializedAfter": "97082", + "Quoter_quoteExactOutput_oneHop_startingInitialized": "52493", + "Quoter_quoteExactOutput_twoHops": "176882" } \ No newline at end of file diff --git a/snapshots/V4RouterTest.json b/snapshots/V4RouterTest.json index f229dbf0..1d9cce47 100644 --- a/snapshots/V4RouterTest.json +++ b/snapshots/V4RouterTest.json @@ -1,26 +1,26 @@ { "V4Router_Bytecode": "7063", - "V4Router_ExactIn1Hop_nativeIn": "115753", - "V4Router_ExactIn1Hop_nativeOut": "116070", - "V4Router_ExactIn1Hop_oneForZero": "124888", - "V4Router_ExactIn1Hop_zeroForOne": "130611", - "V4Router_ExactIn2Hops": "185452", - "V4Router_ExactIn2Hops_nativeIn": "170594", - "V4Router_ExactIn3Hops": "240296", - "V4Router_ExactIn3Hops_nativeIn": "225438", - "V4Router_ExactInputSingle": "129642", - "V4Router_ExactInputSingle_nativeIn": "114784", - "V4Router_ExactInputSingle_nativeOut": "115069", - "V4Router_ExactOut1Hop_nativeIn_sweepETH": "122016", - "V4Router_ExactOut1Hop_nativeOut": "117134", - "V4Router_ExactOut1Hop_oneForZero": "125952", - "V4Router_ExactOut1Hop_zeroForOne": "129897", - "V4Router_ExactOut2Hops": "183800", - "V4Router_ExactOut2Hops_nativeIn": "175919", - "V4Router_ExactOut3Hops": "237734", - "V4Router_ExactOut3Hops_nativeIn": "229853", - "V4Router_ExactOut3Hops_nativeOut": "217089", - "V4Router_ExactOutputSingle": "128925", - "V4Router_ExactOutputSingle_nativeIn_sweepETH": "121044", - "V4Router_ExactOutputSingle_nativeOut": "116236" + "V4Router_ExactIn1Hop_nativeIn": "90557", + "V4Router_ExactIn1Hop_nativeOut": "90874", + "V4Router_ExactIn1Hop_oneForZero": "99212", + "V4Router_ExactIn1Hop_zeroForOne": "104935", + "V4Router_ExactIn2Hops": "152841", + "V4Router_ExactIn2Hops_nativeIn": "144178", + "V4Router_ExactIn3Hops": "200750", + "V4Router_ExactIn3Hops_nativeIn": "192087", + "V4Router_ExactInputSingle": "104210", + "V4Router_ExactInputSingle_nativeIn": "89832", + "V4Router_ExactInputSingle_nativeOut": "90129", + "V4Router_ExactOut1Hop_nativeIn_sweepETH": "96628", + "V4Router_ExactOut1Hop_nativeOut": "91746", + "V4Router_ExactOut1Hop_oneForZero": "100084", + "V4Router_ExactOut1Hop_zeroForOne": "104029", + "V4Router_ExactOut2Hops": "152767", + "V4Router_ExactOut2Hops_nativeIn": "149311", + "V4Router_ExactOut3Hops": "201536", + "V4Router_ExactOut3Hops_nativeIn": "198080", + "V4Router_ExactOut3Hops_nativeOut": "193198", + "V4Router_ExactOutputSingle": "103301", + "V4Router_ExactOutputSingle_nativeIn_sweepETH": "95900", + "V4Router_ExactOutputSingle_nativeOut": "91104" } \ No newline at end of file diff --git a/src/PositionManager.sol b/src/PositionManager.sol index ca3012ee..0a67a9d5 100644 --- a/src/PositionManager.sol +++ b/src/PositionManager.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.26; import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; import {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; -import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; import {Position} from "@uniswap/v4-core/src/libraries/Position.sol"; @@ -26,6 +26,8 @@ import {CalldataDecoder} from "./libraries/CalldataDecoder.sol"; import {Permit2Forwarder} from "./base/Permit2Forwarder.sol"; import {SlippageCheck} from "./libraries/SlippageCheck.sol"; import {PositionInfo, PositionInfoLibrary} from "./libraries/PositionInfoLibrary.sol"; +import {NativeWrapper} from "./base/NativeWrapper.sol"; +import {IWETH9} from "./interfaces/external/IWETH9.sol"; // 444444444 // 444444444444 444444 @@ -102,7 +104,8 @@ contract PositionManager is ReentrancyLock, BaseActionsRouter, Notifier, - Permit2Forwarder + Permit2Forwarder, + NativeWrapper { using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; @@ -126,12 +129,14 @@ contract PositionManager is IPoolManager _poolManager, IAllowanceTransfer _permit2, uint256 _unsubscribeGasLimit, - IPositionDescriptor _tokenDescriptor + IPositionDescriptor _tokenDescriptor, + IWETH9 _weth9 ) BaseActionsRouter(_poolManager) Permit2Forwarder(_permit2) ERC721Permit_v4("Uniswap v4 Positions NFT", "UNI-V4-POSM") Notifier(_unsubscribeGasLimit) + NativeWrapper(_weth9) { tokenDescriptor = _tokenDescriptor; } @@ -242,6 +247,14 @@ contract PositionManager is (Currency currency, address to) = params.decodeCurrencyAndAddress(); _sweep(currency, _mapRecipient(to)); return; + } else if (action == Actions.WRAP) { + uint256 amount = params.decodeUint256(); + _wrap(_mapWrapUnwrapAmount(CurrencyLibrary.ADDRESS_ZERO, amount, Currency.wrap(address(WETH9)))); + return; + } else if (action == Actions.UNWRAP) { + uint256 amount = params.decodeUint256(); + _unwrap(_mapWrapUnwrapAmount(Currency.wrap(address(WETH9)), amount, CurrencyLibrary.ADDRESS_ZERO)); + return; } } revert UnsupportedAction(action); diff --git a/src/base/DeltaResolver.sol b/src/base/DeltaResolver.sol index 57261bd8..85fd1212 100644 --- a/src/base/DeltaResolver.sol +++ b/src/base/DeltaResolver.sol @@ -16,6 +16,8 @@ abstract contract DeltaResolver is ImmutableState { error DeltaNotPositive(Currency currency); /// @notice Emitted trying to take a negative delta. error DeltaNotNegative(Currency currency); + /// @notice Emitted when the contract does not have enough balance to wrap or unwrap. + error InsufficientBalance(); /// @notice Take an amount of currency out of the PoolManager /// @param currency Currency to take @@ -91,4 +93,30 @@ abstract contract DeltaResolver is ImmutableState { return amount; } } + + /// @notice Calculates the sanitized amount before wrapping/unwrapping. + /// @param inputCurrency The currency, either native or wrapped native, that this contract holds + /// @param amount The amount to wrap or unwrap. Can be CONTRACT_BALANCE, OPEN_DELTA or a specific amount + /// @param outputCurrency The currency after the wrap/unwrap that the user may owe a balance in on the poolManager + function _mapWrapUnwrapAmount(Currency inputCurrency, uint256 amount, Currency outputCurrency) + internal + view + returns (uint256) + { + // if wrapping, the balance in this contract is in ETH + // if unwrapping, the balance in this contract is in WETH + uint256 balance = inputCurrency.balanceOf(address(this)); + if (amount == ActionConstants.CONTRACT_BALANCE) { + // return early to avoid unnecessary balance check + return balance; + } + if (amount == ActionConstants.OPEN_DELTA) { + // if wrapping, the open currency on the PoolManager is WETH. + // if unwrapping, the open currency on the PoolManager is ETH. + // note that we use the DEBT amount. Positive deltas can be taken and then wrapped. + amount = _getFullDebt(outputCurrency); + } + if (amount > balance) revert InsufficientBalance(); + return amount; + } } diff --git a/src/base/NativeWrapper.sol b/src/base/NativeWrapper.sol new file mode 100644 index 00000000..ef6a3f2a --- /dev/null +++ b/src/base/NativeWrapper.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {IWETH9} from "../interfaces/external/IWETH9.sol"; +import {ActionConstants} from "../libraries/ActionConstants.sol"; +import {ImmutableState} from "./ImmutableState.sol"; + +/// @title Native Wrapper +/// @notice Used for wrapping and unwrapping native +abstract contract NativeWrapper is ImmutableState { + /// @notice The address for WETH9 + IWETH9 public immutable WETH9; + + /// @notice Thrown when an unexpected address sends ETH to this contract + error InvalidEthSender(); + + constructor(IWETH9 _weth9) { + WETH9 = _weth9; + } + + /// @dev The amount should already be <= the current balance in this contract. + function _wrap(uint256 amount) internal { + if (amount > 0) WETH9.deposit{value: amount}(); + } + + /// @dev The amount should already be <= the current balance in this contract. + function _unwrap(uint256 amount) internal { + if (amount > 0) WETH9.withdraw(amount); + } + + receive() external payable { + if (msg.sender != address(WETH9) && msg.sender != address(poolManager)) revert InvalidEthSender(); + } +} diff --git a/src/interfaces/external/IWETH9.sol b/src/interfaces/external/IWETH9.sol new file mode 100644 index 00000000..37c3be8e --- /dev/null +++ b/src/interfaces/external/IWETH9.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title Interface for WETH9 +interface IWETH9 is IERC20 { + /// @notice Deposit ether to get wrapped ether + function deposit() external payable; + + /// @notice Withdraw wrapped ether to get ether + function withdraw(uint256) external; +} diff --git a/src/libraries/Actions.sol b/src/libraries/Actions.sol index 49d3e04f..16a8ce8c 100644 --- a/src/libraries/Actions.sol +++ b/src/libraries/Actions.sol @@ -33,8 +33,10 @@ library Actions { uint256 constant CLOSE_CURRENCY = 0x17; uint256 constant CLEAR_OR_TAKE = 0x18; uint256 constant SWEEP = 0x19; + uint256 constant WRAP = 0x20; + uint256 constant UNWRAP = 0x21; // minting/burning 6909s to close deltas - uint256 constant MINT_6909 = 0x20; - uint256 constant BURN_6909 = 0x21; + uint256 constant MINT_6909 = 0x22; + uint256 constant BURN_6909 = 0x23; } diff --git a/src/libraries/CalldataDecoder.sol b/src/libraries/CalldataDecoder.sol index 00cf6e33..a14f3a34 100644 --- a/src/libraries/CalldataDecoder.sol +++ b/src/libraries/CalldataDecoder.sol @@ -241,6 +241,13 @@ library CalldataDecoder { } } + /// @dev equivalent to: abi.decode(params, (uint256)) in calldata + function decodeUint256(bytes calldata params) internal pure returns (uint256 amount) { + assembly ("memory-safe") { + amount := calldataload(params.offset) + } + } + /// @dev equivalent to: abi.decode(params, (Currency, uint256, bool)) in calldata function decodeCurrencyUint256AndBool(bytes calldata params) internal diff --git a/test/PositionDescriptor.t.sol b/test/PositionDescriptor.t.sol index 211e705e..f3cd168d 100644 --- a/test/PositionDescriptor.t.sol +++ b/test/PositionDescriptor.t.sol @@ -121,7 +121,7 @@ contract PositionDescriptorTest is Test, PosmTestSetup { assertEq(token.name, "Uniswap - 0.3% - TEST/TEST - 1.0060<>1.0121"); assertEq( token.description, - unicode"This NFT represents a liquidity position in a Uniswap v4 TEST-TEST pool. The owner of this NFT can modify or redeem the position.\n\nPool Manager Address: 0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f\nTEST Address: 0x5991a2df15a8f6a256d3ec51e99254cd3fb576a9\nTEST Address: 0x2e234dae75c793f67a35089c9d99245e1c58470b\nHook Address: No Hook\nFee Tier: 0.3%\nToken ID: 1\n\n⚠️ DISCLAIMER: Due diligence is imperative when assessing this NFT. Make sure currency addresses match the expected currencies, as currency symbols may be imitated." + unicode"This NFT represents a liquidity position in a Uniswap v4 TEST-TEST pool. The owner of this NFT can modify or redeem the position.\n\nPool Manager Address: 0x2e234dae75c793f67a35089c9d99245e1c58470b\nTEST Address: 0xf62849f9a0b5bf2913b396098f7c7019b51a820a\nTEST Address: 0xc7183455a4c133ae270771860664b6b7ec320bb1\nHook Address: No Hook\nFee Tier: 0.3%\nToken ID: 1\n\n⚠️ DISCLAIMER: Due diligence is imperative when assessing this NFT. Make sure currency addresses match the expected currencies, as currency symbols may be imitated." ); } diff --git a/test/libraries/CalldataDecoder.t.sol b/test/libraries/CalldataDecoder.t.sol index c41122eb..de100667 100644 --- a/test/libraries/CalldataDecoder.t.sol +++ b/test/libraries/CalldataDecoder.t.sol @@ -232,6 +232,13 @@ contract CalldataDecoderTest is Test { assertEq(amount, _amount); } + function test_fuzz_decodeUint256(uint256 _amount) public { + bytes memory params = abi.encode(_amount); + uint256 amount = decoder.decodeUint256(params); + + assertEq(amount, _amount); + } + function _assertEq(PathKey[] memory path1, PathKey[] memory path2) internal pure { assertEq(path1.length, path2.length); for (uint256 i = 0; i < path1.length; i++) { diff --git a/test/mocks/MockCalldataDecoder.sol b/test/mocks/MockCalldataDecoder.sol index e25d8ec7..c61db4af 100644 --- a/test/mocks/MockCalldataDecoder.sol +++ b/test/mocks/MockCalldataDecoder.sol @@ -136,4 +136,8 @@ contract MockCalldataDecoder { { return params.decodeCurrencyAddressAndUint256(); } + + function decodeUint256(bytes calldata params) external pure returns (uint256) { + return params.decodeUint256(); + } } diff --git a/test/position-managers/PositionManager.modifyLiquidities.t.sol b/test/position-managers/PositionManager.modifyLiquidities.t.sol index e71c121f..c805dd27 100644 --- a/test/position-managers/PositionManager.modifyLiquidities.t.sol +++ b/test/position-managers/PositionManager.modifyLiquidities.t.sol @@ -2,6 +2,8 @@ pragma solidity ^0.8.24; import "forge-std/Test.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.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"; @@ -13,8 +15,11 @@ import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; import {Position} from "@uniswap/v4-core/src/libraries/Position.sol"; import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; +import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; import {IPositionManager} from "../../src/interfaces/IPositionManager.sol"; +import {IMulticall_v4} from "../../src/interfaces/IMulticall_v4.sol"; import {ReentrancyLock} from "../../src/base/ReentrancyLock.sol"; import {Actions} from "../../src/libraries/Actions.sol"; import {PositionManager} from "../../src/PositionManager.sol"; @@ -23,10 +28,16 @@ import {PositionConfig} from "../shared/PositionConfig.sol"; import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; import {Planner, Plan} from "../shared/Planner.sol"; import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; +import {ActionConstants} from "../../src/libraries/ActionConstants.sol"; +import {Planner, Plan} from "../shared/Planner.sol"; +import {DeltaResolver} from "../../src/base/DeltaResolver.sol"; + +import "forge-std/console2.sol"; contract PositionManagerModifyLiquiditiesTest is Test, PosmTestSetup, LiquidityFuzzers { using StateLibrary for IPoolManager; using PoolIdLibrary for PoolKey; + using Planner for Plan; PoolId poolId; address alice; @@ -34,6 +45,8 @@ contract PositionManagerModifyLiquiditiesTest is Test, PosmTestSetup, LiquidityF address bob; PositionConfig config; + PositionConfig wethConfig; + PositionConfig nativeConfig; function setUp() public { (alice, alicePK) = makeAddrAndKey("ALICE"); @@ -54,8 +67,23 @@ contract PositionManagerModifyLiquiditiesTest is Test, PosmTestSetup, LiquidityF seedBalance(address(hookModifyLiquidities)); (key, poolId) = initPool(currency0, currency1, IHooks(hookModifyLiquidities), 3000, SQRT_PRICE_1_1); + initWethPool(currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1); + + seedWeth(address(this)); + approvePosmCurrency(Currency.wrap(address(_WETH9))); + + nativeKey = PoolKey(CurrencyLibrary.ADDRESS_ZERO, currency1, 3000, 60, IHooks(address(0))); + manager.initialize(nativeKey, SQRT_PRICE_1_1); config = PositionConfig({poolKey: key, tickLower: -60, tickUpper: 60}); + wethConfig = PositionConfig({ + poolKey: wethKey, + tickLower: TickMath.minUsableTick(wethKey.tickSpacing), + tickUpper: TickMath.maxUsableTick(wethKey.tickSpacing) + }); + nativeConfig = PositionConfig({poolKey: nativeKey, tickLower: -120, tickUpper: 120}); + + vm.deal(address(this), 1000 ether); } /// @dev minting liquidity without approval is allowable @@ -308,4 +336,353 @@ contract PositionManagerModifyLiquiditiesTest is Test, PosmTestSetup, LiquidityF ); lpm.modifyLiquidities(calls, _deadline); } + + function test_wrap_mint_usingContractBalance() public { + // weth-currency1 pool initialized as wethKey + // input: eth, currency1 + // modifyLiquidities call to mint liquidity weth and currency1 + // 1 _wrap with contract balance + // 2 _mint + // 3 _settle weth where the payer is the contract + // 4 _close currency1, payer is caller + // 5 _sweep weth since eth was entirely wrapped + + uint256 balanceEthBefore = address(this).balance; + uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + uint256 tokenId = lpm.nextTokenId(); + + uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(wethConfig.tickLower), + TickMath.getSqrtPriceAtTick(wethConfig.tickUpper), + 100 ether, + 100 ether + ); + + Plan memory planner = Planner.init(); + planner.add(Actions.WRAP, abi.encode(ActionConstants.CONTRACT_BALANCE)); + planner.add( + Actions.MINT_POSITION, + abi.encode( + wethConfig.poolKey, + wethConfig.tickLower, + wethConfig.tickUpper, + liquidityAmount, + MAX_SLIPPAGE_INCREASE, + MAX_SLIPPAGE_INCREASE, + ActionConstants.MSG_SENDER, + ZERO_BYTES + ) + ); + + // weth9 payer is the contract + planner.add(Actions.SETTLE, abi.encode(address(_WETH9), ActionConstants.OPEN_DELTA, false)); + // other currency can close normally + planner.add(Actions.CLOSE_CURRENCY, abi.encode(currency1)); + // we wrapped the full contract balance so we sweep back in the wrapped currency + planner.add(Actions.SWEEP, abi.encode(address(_WETH9), ActionConstants.MSG_SENDER)); + bytes memory actions = planner.encode(); + + // Overestimate eth amount. + lpm.modifyLiquidities{value: 102 ether}(actions, _deadline); + + uint256 balanceEthAfter = address(this).balance; + uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + + // The full eth amount was "spent" because some was wrapped into weth and refunded. + assertApproxEqAbs(balanceEthBefore - balanceEthAfter, 102 ether, 1 wei); + assertApproxEqAbs(balance1Before - balance1After, 100 ether, 1 wei); + assertEq(lpm.ownerOf(tokenId), address(this)); + assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount); + assertEq(_WETH9.balanceOf(address(lpm)), 0); + assertEq(address(lpm).balance, 0); + } + + function test_wrap_mint_openDelta() public { + // weth-currency1 pool initialized as wethKey + // input: eth, currency1 + // modifyLiquidities call to mint liquidity weth and currency1 + // 1 _mint + // 2 _wrap with open delta + // 3 _settle weth where the payer is the contract + // 4 _close currency1, payer is caller + // 5 _sweep eth since only the open delta amount was wrapped + + uint256 balanceEthBefore = address(this).balance; + uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + uint256 tokenId = lpm.nextTokenId(); + + uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(wethConfig.tickLower), + TickMath.getSqrtPriceAtTick(wethConfig.tickUpper), + 100 ether, + 100 ether + ); + + Plan memory planner = Planner.init(); + + planner.add( + Actions.MINT_POSITION, + abi.encode( + wethConfig.poolKey, + wethConfig.tickLower, + wethConfig.tickUpper, + liquidityAmount, + MAX_SLIPPAGE_INCREASE, + MAX_SLIPPAGE_INCREASE, + ActionConstants.MSG_SENDER, + ZERO_BYTES + ) + ); + + planner.add(Actions.WRAP, abi.encode(ActionConstants.OPEN_DELTA)); + + // weth9 payer is the contract + planner.add(Actions.SETTLE, abi.encode(address(_WETH9), ActionConstants.OPEN_DELTA, false)); + // other currency can close normally + planner.add(Actions.CLOSE_CURRENCY, abi.encode(currency1)); + // we wrapped the open delta balance so we sweep back in the native currency + planner.add(Actions.SWEEP, abi.encode(CurrencyLibrary.ADDRESS_ZERO, ActionConstants.MSG_SENDER)); + bytes memory actions = planner.encode(); + + lpm.modifyLiquidities{value: 102 ether}(actions, _deadline); + + uint256 balanceEthAfter = address(this).balance; + uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + + // Approx 100 eth was spent because the extra 2 were refunded. + assertApproxEqAbs(balanceEthBefore - balanceEthAfter, 100 ether, 1 wei); + assertApproxEqAbs(balance1Before - balance1After, 100 ether, 1 wei); + assertEq(lpm.ownerOf(tokenId), address(this)); + assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount); + assertEq(_WETH9.balanceOf(address(lpm)), 0); + assertEq(address(lpm).balance, 0); + } + + function test_wrap_mint_usingExactAmount() public { + // weth-currency1 pool initialized as wethKey + // input: eth, currency1 + // modifyLiquidities call to mint liquidity weth and currency1 + // 1 _wrap with an amount + // 2 _mint + // 3 _settle weth where the payer is the contract + // 4 _close currency1, payer is caller + // 5 _sweep weth since eth was entirely wrapped + + uint256 balanceEthBefore = address(this).balance; + uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + uint256 tokenId = lpm.nextTokenId(); + + uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(wethConfig.tickLower), + TickMath.getSqrtPriceAtTick(wethConfig.tickUpper), + 100 ether, + 100 ether + ); + + Plan memory planner = Planner.init(); + planner.add(Actions.WRAP, abi.encode(100 ether)); + planner.add( + Actions.MINT_POSITION, + abi.encode( + wethConfig.poolKey, + wethConfig.tickLower, + wethConfig.tickUpper, + liquidityAmount, + MAX_SLIPPAGE_INCREASE, + MAX_SLIPPAGE_INCREASE, + ActionConstants.MSG_SENDER, + ZERO_BYTES + ) + ); + + // weth9 payer is the contract + planner.add(Actions.SETTLE, abi.encode(address(_WETH9), ActionConstants.OPEN_DELTA, false)); + // other currency can close normally + planner.add(Actions.CLOSE_CURRENCY, abi.encode(currency1)); + // we wrapped all 100 eth so we sweep back in the wrapped currency for safety measure + planner.add(Actions.SWEEP, abi.encode(address(_WETH9), ActionConstants.MSG_SENDER)); + bytes memory actions = planner.encode(); + + lpm.modifyLiquidities{value: 100 ether}(actions, _deadline); + + uint256 balanceEthAfter = address(this).balance; + uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + + // The full eth amount was "spent" because some was wrapped into weth and refunded. + assertApproxEqAbs(balanceEthBefore - balanceEthAfter, 100 ether, 1 wei); + assertApproxEqAbs(balance1Before - balance1After, 100 ether, 1 wei); + assertEq(lpm.ownerOf(tokenId), address(this)); + assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount); + assertEq(_WETH9.balanceOf(address(lpm)), 0); + assertEq(address(lpm).balance, 0); + } + + function test_wrap_mint_revertsInsufficientBalance() public { + // 1 _wrap with more eth than is sent in + + Plan memory planner = Planner.init(); + // Wrap more eth than what is sent in. + planner.add(Actions.WRAP, abi.encode(101 ether)); + + bytes memory actions = planner.encode(); + + vm.expectRevert(DeltaResolver.InsufficientBalance.selector); + lpm.modifyLiquidities{value: 100 ether}(actions, _deadline); + } + + function test_unwrap_usingContractBalance() public { + // weth-currency1 pool + // output: eth, currency1 + // modifyLiquidities call to mint liquidity weth and currency1 + // 1 _burn + // 2 _take where the weth is sent to the lpm contract + // 3 _take where currency1 is sent to the msg sender + // 4 _unwrap using contract balance + // 5 _sweep where eth is sent to msg sender + uint256 tokenId = lpm.nextTokenId(); + + uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(wethConfig.tickLower), + TickMath.getSqrtPriceAtTick(wethConfig.tickUpper), + 100 ether, + 100 ether + ); + + bytes memory actions = getMintEncoded(wethConfig, liquidityAmount, address(this), ZERO_BYTES); + lpm.modifyLiquidities(actions, _deadline); + + assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount); + + uint256 balanceEthBefore = address(this).balance; + uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + + Plan memory planner = Planner.init(); + planner.add( + Actions.BURN_POSITION, abi.encode(tokenId, MIN_SLIPPAGE_DECREASE, MIN_SLIPPAGE_DECREASE, ZERO_BYTES) + ); + // take the weth to the position manager to be unwrapped + planner.add(Actions.TAKE, abi.encode(address(_WETH9), ActionConstants.ADDRESS_THIS, ActionConstants.OPEN_DELTA)); + planner.add( + Actions.TAKE, + abi.encode(address(Currency.unwrap(currency1)), ActionConstants.MSG_SENDER, ActionConstants.OPEN_DELTA) + ); + planner.add(Actions.UNWRAP, abi.encode(ActionConstants.CONTRACT_BALANCE)); + planner.add(Actions.SWEEP, abi.encode(CurrencyLibrary.ADDRESS_ZERO, ActionConstants.MSG_SENDER)); + + actions = planner.encode(); + + lpm.modifyLiquidities(actions, _deadline); + + uint256 balanceEthAfter = address(this).balance; + uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + + assertApproxEqAbs(balanceEthAfter - balanceEthBefore, 100 ether, 1 wei); + assertApproxEqAbs(balance1After - balance1Before, 100 ether, 1 wei); + assertEq(lpm.getPositionLiquidity(tokenId), 0); + assertEq(_WETH9.balanceOf(address(lpm)), 0); + assertEq(address(lpm).balance, 0); + } + + function test_unwrap_openDelta_reinvest() public { + // weth-currency1 pool rolls half to eth-currency1 pool + // output: eth, currency1 + // modifyLiquidities call to mint liquidity weth and currency1 + // 1 _burn (weth-currency1) + // 2 _take where the weth is sent to the lpm contract + // 4 _mint to an eth pool + // 4 _unwrap using open delta (pool managers ETH balance) + // 3 _take where leftover currency1 is sent to the msg sender + // 5 _settle eth open delta + // 5 _sweep leftover weth + + uint256 tokenId = lpm.nextTokenId(); + + uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(wethConfig.tickLower), + TickMath.getSqrtPriceAtTick(wethConfig.tickUpper), + 100 ether, + 100 ether + ); + + bytes memory actions = getMintEncoded(wethConfig, liquidityAmount, address(this), ZERO_BYTES); + lpm.modifyLiquidities(actions, _deadline); + + assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount); + + uint256 balanceEthBefore = address(this).balance; + uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + uint256 balanceWethBefore = _WETH9.balanceOf(address(this)); + + uint128 newLiquidityAmount = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(nativeConfig.tickLower), + TickMath.getSqrtPriceAtTick(nativeConfig.tickUpper), + 50 ether, + 50 ether + ); + + Plan memory planner = Planner.init(); + planner.add( + Actions.BURN_POSITION, abi.encode(tokenId, MIN_SLIPPAGE_DECREASE, MIN_SLIPPAGE_DECREASE, ZERO_BYTES) + ); + // take the weth to the position manager to be unwrapped + planner.add(Actions.TAKE, abi.encode(address(_WETH9), ActionConstants.ADDRESS_THIS, ActionConstants.OPEN_DELTA)); + planner.add( + Actions.MINT_POSITION, + abi.encode( + nativeConfig.poolKey, + nativeConfig.tickLower, + nativeConfig.tickUpper, + newLiquidityAmount, + MAX_SLIPPAGE_INCREASE, + MAX_SLIPPAGE_INCREASE, + ActionConstants.MSG_SENDER, + ZERO_BYTES + ) + ); + planner.add(Actions.UNWRAP, abi.encode(ActionConstants.OPEN_DELTA)); + // pay the eth + planner.add(Actions.SETTLE, abi.encode(CurrencyLibrary.ADDRESS_ZERO, ActionConstants.OPEN_DELTA, false)); + // take the leftover currency1 + planner.add( + Actions.TAKE, + abi.encode(address(Currency.unwrap(currency1)), ActionConstants.MSG_SENDER, ActionConstants.OPEN_DELTA) + ); + planner.add(Actions.SWEEP, abi.encode(address(_WETH9), ActionConstants.MSG_SENDER)); + + actions = planner.encode(); + + lpm.modifyLiquidities(actions, _deadline); + + uint256 balanceEthAfter = address(this).balance; + uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + uint256 balanceWethAfter = _WETH9.balanceOf(address(this)); + + // Eth balance should not change. + assertEq(balanceEthAfter, balanceEthBefore); + // Only half of the original liquidity was reinvested. + assertApproxEqAbs(balance1After - balance1Before, 50 ether, 1 wei); + assertApproxEqAbs(balanceWethAfter - balanceWethBefore, 50 ether, 1 wei); + assertEq(lpm.getPositionLiquidity(tokenId), 0); + assertEq(_WETH9.balanceOf(address(lpm)), 0); + assertEq(address(lpm).balance, 0); + } + + function test_unwrap_revertsInsufficientBalance() public { + // 1 _unwrap with more than is in the contract + + Plan memory planner = Planner.init(); + // unwraps more eth than what is in the contract + planner.add(Actions.UNWRAP, abi.encode(101 ether)); + + bytes memory actions = planner.encode(); + + vm.expectRevert(DeltaResolver.InsufficientBalance.selector); + lpm.modifyLiquidities(actions, _deadline); + } } diff --git a/test/shared/PosmTestSetup.sol b/test/shared/PosmTestSetup.sol index 0a79edd1..22e09ed9 100644 --- a/test/shared/PosmTestSetup.sol +++ b/test/shared/PosmTestSetup.sol @@ -17,6 +17,12 @@ import {HookSavesDelta} from "./HookSavesDelta.sol"; import {HookModifyLiquidities} from "./HookModifyLiquidities.sol"; import {PositionDescriptor} from "../../src/PositionDescriptor.sol"; import {ERC721PermitHash} from "../../src/libraries/ERC721PermitHash.sol"; +import {IWETH9} from "../../src/interfaces/external/IWETH9.sol"; +import {WETH} from "solmate/src/tokens/WETH.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {SortTokens} from "@uniswap/v4-core/test/utils/SortTokens.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {PositionConfig} from "../shared/PositionConfig.sol"; /// @notice A shared test contract that wraps the v4-core deployers contract and exposes basic liquidity operations on posm. contract PosmTestSetup is Test, Deployers, DeployPermit2, LiquidityOperations { @@ -26,6 +32,7 @@ contract PosmTestSetup is Test, Deployers, DeployPermit2, LiquidityOperations { PositionDescriptor public positionDescriptor; HookSavesDelta hook; address hookAddr = address(uint160(Hooks.AFTER_ADD_LIQUIDITY_FLAG | Hooks.AFTER_REMOVE_LIQUIDITY_FLAG)); + IWETH9 public _WETH9 = IWETH9(address(new WETH())); HookModifyLiquidities hookModifyLiquidities; address hookModifyLiquiditiesAddr = address( @@ -35,6 +42,8 @@ contract PosmTestSetup is Test, Deployers, DeployPermit2, LiquidityOperations { ) ); + PoolKey wethKey; + function deployPosmHookSavesDelta() public { HookSavesDelta impl = new HookSavesDelta(); vm.etch(hookAddr, address(impl).code); @@ -60,7 +69,7 @@ contract PosmTestSetup is Test, Deployers, DeployPermit2, LiquidityOperations { // We use deployPermit2() to prevent having to use via-ir in this repository. permit2 = IAllowanceTransfer(deployPermit2()); positionDescriptor = new PositionDescriptor(poolManager, 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, "ETH"); - lpm = new PositionManager(poolManager, permit2, 100_000, positionDescriptor); + lpm = new PositionManager(poolManager, permit2, 100_000, positionDescriptor, _WETH9); } function seedBalance(address to) internal { @@ -88,6 +97,19 @@ contract PosmTestSetup is Test, Deployers, DeployPermit2, LiquidityOperations { vm.stopPrank(); } + function seedWeth(address to) internal { + vm.deal(address(this), STARTING_USER_BALANCE); + _WETH9.deposit{value: STARTING_USER_BALANCE}(); + _WETH9.transfer(to, STARTING_USER_BALANCE); + } + + function initWethPool(Currency currencyB, IHooks hooks, uint24 fee, uint160 sqrtPriceX96) internal { + (Currency _currency0, Currency _currency1) = + SortTokens.sort(MockERC20(address(_WETH9)), MockERC20(Currency.unwrap(currencyB))); + + (wethKey,) = initPool(_currency0, _currency1, hooks, fee, sqrtPriceX96); + } + function permit(uint256 privateKey, uint256 tokenId, address operator, uint256 nonce) internal { bytes32 digest = getDigest(operator, tokenId, 1, block.timestamp + 1);