diff --git a/.forge-snapshots/FullOracleObserve0After5Seconds.snap b/.forge-snapshots/FullOracleObserve0After5Seconds.snap deleted file mode 100644 index f5b9e8bf..00000000 --- a/.forge-snapshots/FullOracleObserve0After5Seconds.snap +++ /dev/null @@ -1 +0,0 @@ -1912 \ No newline at end of file diff --git a/.forge-snapshots/FullOracleObserve200By13.snap b/.forge-snapshots/FullOracleObserve200By13.snap deleted file mode 100644 index b47b8dc4..00000000 --- a/.forge-snapshots/FullOracleObserve200By13.snap +++ /dev/null @@ -1 +0,0 @@ -20210 \ No newline at end of file diff --git a/.forge-snapshots/FullOracleObserve200By13Plus5.snap b/.forge-snapshots/FullOracleObserve200By13Plus5.snap deleted file mode 100644 index 46616951..00000000 --- a/.forge-snapshots/FullOracleObserve200By13Plus5.snap +++ /dev/null @@ -1 +0,0 @@ -20443 \ No newline at end of file diff --git a/.forge-snapshots/FullOracleObserve5After5Seconds.snap b/.forge-snapshots/FullOracleObserve5After5Seconds.snap deleted file mode 100644 index dba60802..00000000 --- a/.forge-snapshots/FullOracleObserve5After5Seconds.snap +++ /dev/null @@ -1 +0,0 @@ -2024 \ No newline at end of file diff --git a/.forge-snapshots/FullOracleObserveOldest.snap b/.forge-snapshots/FullOracleObserveOldest.snap deleted file mode 100644 index c90bb2fe..00000000 --- a/.forge-snapshots/FullOracleObserveOldest.snap +++ /dev/null @@ -1 +0,0 @@ -19279 \ No newline at end of file diff --git a/.forge-snapshots/FullOracleObserveOldestAfter5Seconds.snap b/.forge-snapshots/FullOracleObserveOldestAfter5Seconds.snap deleted file mode 100644 index 1d23504b..00000000 --- a/.forge-snapshots/FullOracleObserveOldestAfter5Seconds.snap +++ /dev/null @@ -1 +0,0 @@ -19555 \ No newline at end of file diff --git a/.forge-snapshots/FullOracleObserveZero.snap b/.forge-snapshots/FullOracleObserveZero.snap deleted file mode 100644 index 3559f242..00000000 --- a/.forge-snapshots/FullOracleObserveZero.snap +++ /dev/null @@ -1 +0,0 @@ -1477 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeAddInitialLiquidity.snap b/.forge-snapshots/FullRangeAddInitialLiquidity.snap deleted file mode 100644 index 59e98b10..00000000 --- a/.forge-snapshots/FullRangeAddInitialLiquidity.snap +++ /dev/null @@ -1 +0,0 @@ -309858 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeAddLiquidity.snap b/.forge-snapshots/FullRangeAddLiquidity.snap deleted file mode 100644 index 45f04866..00000000 --- a/.forge-snapshots/FullRangeAddLiquidity.snap +++ /dev/null @@ -1 +0,0 @@ -121640 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeFirstSwap.snap b/.forge-snapshots/FullRangeFirstSwap.snap deleted file mode 100644 index 34462841..00000000 --- a/.forge-snapshots/FullRangeFirstSwap.snap +++ /dev/null @@ -1 +0,0 @@ -78977 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeInitialize.snap b/.forge-snapshots/FullRangeInitialize.snap deleted file mode 100644 index 3451c605..00000000 --- a/.forge-snapshots/FullRangeInitialize.snap +++ /dev/null @@ -1 +0,0 @@ -1008717 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeRemoveLiquidity.snap b/.forge-snapshots/FullRangeRemoveLiquidity.snap deleted file mode 100644 index de65ef10..00000000 --- a/.forge-snapshots/FullRangeRemoveLiquidity.snap +++ /dev/null @@ -1 +0,0 @@ -109724 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap b/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap deleted file mode 100644 index b1b786de..00000000 --- a/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap +++ /dev/null @@ -1 +0,0 @@ -239812 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeSecondSwap.snap b/.forge-snapshots/FullRangeSecondSwap.snap deleted file mode 100644 index 4a24b0e5..00000000 --- a/.forge-snapshots/FullRangeSecondSwap.snap +++ /dev/null @@ -1 +0,0 @@ -44555 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeSwap.snap b/.forge-snapshots/FullRangeSwap.snap deleted file mode 100644 index 7d69ddee..00000000 --- a/.forge-snapshots/FullRangeSwap.snap +++ /dev/null @@ -1 +0,0 @@ -78130 \ No newline at end of file diff --git a/.forge-snapshots/OracleGrow10Slots.snap b/.forge-snapshots/OracleGrow10Slots.snap deleted file mode 100644 index 3dada479..00000000 --- a/.forge-snapshots/OracleGrow10Slots.snap +++ /dev/null @@ -1 +0,0 @@ -232960 \ No newline at end of file diff --git a/.forge-snapshots/OracleGrow10SlotsCardinalityGreater.snap b/.forge-snapshots/OracleGrow10SlotsCardinalityGreater.snap deleted file mode 100644 index f623cfa5..00000000 --- a/.forge-snapshots/OracleGrow10SlotsCardinalityGreater.snap +++ /dev/null @@ -1 +0,0 @@ -223649 \ No newline at end of file diff --git a/.forge-snapshots/OracleGrow1Slot.snap b/.forge-snapshots/OracleGrow1Slot.snap deleted file mode 100644 index 137baa16..00000000 --- a/.forge-snapshots/OracleGrow1Slot.snap +++ /dev/null @@ -1 +0,0 @@ -32845 \ No newline at end of file diff --git a/.forge-snapshots/OracleGrow1SlotCardinalityGreater.snap b/.forge-snapshots/OracleGrow1SlotCardinalityGreater.snap deleted file mode 100644 index e6dc42ce..00000000 --- a/.forge-snapshots/OracleGrow1SlotCardinalityGreater.snap +++ /dev/null @@ -1 +0,0 @@ -23545 \ No newline at end of file diff --git a/.forge-snapshots/OracleInitialize.snap b/.forge-snapshots/OracleInitialize.snap deleted file mode 100644 index e4e9e6b2..00000000 --- a/.forge-snapshots/OracleInitialize.snap +++ /dev/null @@ -1 +0,0 @@ -51310 \ No newline at end of file diff --git a/.forge-snapshots/OracleObserveBetweenOldestAndOldestPlusOne.snap b/.forge-snapshots/OracleObserveBetweenOldestAndOldestPlusOne.snap deleted file mode 100644 index 5996d53e..00000000 --- a/.forge-snapshots/OracleObserveBetweenOldestAndOldestPlusOne.snap +++ /dev/null @@ -1 +0,0 @@ -5368 \ No newline at end of file diff --git a/.forge-snapshots/OracleObserveCurrentTime.snap b/.forge-snapshots/OracleObserveCurrentTime.snap deleted file mode 100644 index 3559f242..00000000 --- a/.forge-snapshots/OracleObserveCurrentTime.snap +++ /dev/null @@ -1 +0,0 @@ -1477 \ No newline at end of file diff --git a/.forge-snapshots/OracleObserveCurrentTimeCounterfactual.snap b/.forge-snapshots/OracleObserveCurrentTimeCounterfactual.snap deleted file mode 100644 index 3559f242..00000000 --- a/.forge-snapshots/OracleObserveCurrentTimeCounterfactual.snap +++ /dev/null @@ -1 +0,0 @@ -1477 \ No newline at end of file diff --git a/.forge-snapshots/OracleObserveLast20Seconds.snap b/.forge-snapshots/OracleObserveLast20Seconds.snap deleted file mode 100644 index 24efe8f4..00000000 --- a/.forge-snapshots/OracleObserveLast20Seconds.snap +++ /dev/null @@ -1 +0,0 @@ -73037 \ No newline at end of file diff --git a/.forge-snapshots/OracleObserveLatestEqual.snap b/.forge-snapshots/OracleObserveLatestEqual.snap deleted file mode 100644 index 3559f242..00000000 --- a/.forge-snapshots/OracleObserveLatestEqual.snap +++ /dev/null @@ -1 +0,0 @@ -1477 \ No newline at end of file diff --git a/.forge-snapshots/OracleObserveLatestTransform.snap b/.forge-snapshots/OracleObserveLatestTransform.snap deleted file mode 100644 index f5b9e8bf..00000000 --- a/.forge-snapshots/OracleObserveLatestTransform.snap +++ /dev/null @@ -1 +0,0 @@ -1912 \ No newline at end of file diff --git a/.forge-snapshots/OracleObserveMiddle.snap b/.forge-snapshots/OracleObserveMiddle.snap deleted file mode 100644 index 76e5b53e..00000000 --- a/.forge-snapshots/OracleObserveMiddle.snap +++ /dev/null @@ -1 +0,0 @@ -5541 \ No newline at end of file diff --git a/.forge-snapshots/OracleObserveOldest.snap b/.forge-snapshots/OracleObserveOldest.snap deleted file mode 100644 index f124ce2d..00000000 --- a/.forge-snapshots/OracleObserveOldest.snap +++ /dev/null @@ -1 +0,0 @@ -5092 \ No newline at end of file diff --git a/.forge-snapshots/OracleObserveSinceMostRecent.snap b/.forge-snapshots/OracleObserveSinceMostRecent.snap deleted file mode 100644 index 9dab3404..00000000 --- a/.forge-snapshots/OracleObserveSinceMostRecent.snap +++ /dev/null @@ -1 +0,0 @@ -2522 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect.snap b/.forge-snapshots/PositionManager_collect.snap new file mode 100644 index 00000000..ad535d66 --- /dev/null +++ b/.forge-snapshots/PositionManager_collect.snap @@ -0,0 +1 @@ +162386 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect_sameRange.snap b/.forge-snapshots/PositionManager_collect_sameRange.snap new file mode 100644 index 00000000..ad535d66 --- /dev/null +++ b/.forge-snapshots/PositionManager_collect_sameRange.snap @@ -0,0 +1 @@ +162386 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decreaseLiquidity.snap b/.forge-snapshots/PositionManager_decreaseLiquidity.snap new file mode 100644 index 00000000..4c00c495 --- /dev/null +++ b/.forge-snapshots/PositionManager_decreaseLiquidity.snap @@ -0,0 +1 @@ +127764 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap b/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap new file mode 100644 index 00000000..829d911f --- /dev/null +++ b/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap @@ -0,0 +1 @@ +140645 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increaseLiquidity_erc20.snap b/.forge-snapshots/PositionManager_increaseLiquidity_erc20.snap new file mode 100644 index 00000000..47d4166c --- /dev/null +++ b/.forge-snapshots/PositionManager_increaseLiquidity_erc20.snap @@ -0,0 +1 @@ +157182 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap b/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap new file mode 100644 index 00000000..41f6d2f0 --- /dev/null +++ b/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap @@ -0,0 +1 @@ +148692 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap b/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap new file mode 100644 index 00000000..8dd57a0a --- /dev/null +++ b/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap @@ -0,0 +1 @@ +184956 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint.snap b/.forge-snapshots/PositionManager_mint.snap new file mode 100644 index 00000000..f0022990 --- /dev/null +++ b/.forge-snapshots/PositionManager_mint.snap @@ -0,0 +1 @@ +418192 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_onSameTickLower.snap b/.forge-snapshots/PositionManager_mint_onSameTickLower.snap new file mode 100644 index 00000000..6be7a189 --- /dev/null +++ b/.forge-snapshots/PositionManager_mint_onSameTickLower.snap @@ -0,0 +1 @@ +360874 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap b/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap new file mode 100644 index 00000000..c3f898bc --- /dev/null +++ b/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap @@ -0,0 +1 @@ +361516 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_sameRange.snap b/.forge-snapshots/PositionManager_mint_sameRange.snap new file mode 100644 index 00000000..6ba2ef01 --- /dev/null +++ b/.forge-snapshots/PositionManager_mint_sameRange.snap @@ -0,0 +1 @@ +287098 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap b/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap new file mode 100644 index 00000000..86a7d8c2 --- /dev/null +++ b/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap @@ -0,0 +1 @@ +366892 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_multicall_initialize_mint.snap b/.forge-snapshots/PositionManager_multicall_initialize_mint.snap new file mode 100644 index 00000000..59036d4e --- /dev/null +++ b/.forge-snapshots/PositionManager_multicall_initialize_mint.snap @@ -0,0 +1 @@ +462111 \ No newline at end of file diff --git a/.forge-snapshots/TWAMMSubmitOrder.snap b/.forge-snapshots/TWAMMSubmitOrder.snap deleted file mode 100644 index 3d61294d..00000000 --- a/.forge-snapshots/TWAMMSubmitOrder.snap +++ /dev/null @@ -1 +0,0 @@ -122043 \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9d16a66b..b0eb346e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,5 +21,5 @@ jobs: with: version: nightly - - name: Run tests + - name: Check format run: forge fmt --check diff --git a/.gitmodules b/.gitmodules index d2dc450b..b6d49e52 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,15 +1,3 @@ -[submodule "lib/forge-std"] - path = lib/forge-std - url = https://github.com/foundry-rs/forge-std -[submodule "lib/openzeppelin-contracts"] - path = lib/openzeppelin-contracts - url = https://github.com/OpenZeppelin/openzeppelin-contracts -[submodule "lib/forge-gas-snapshot"] - path = lib/forge-gas-snapshot - url = https://github.com/marktoda/forge-gas-snapshot [submodule "lib/v4-core"] path = lib/v4-core url = https://github.com/Uniswap/v4-core -[submodule "lib/solmate"] - path = lib/solmate - url = https://github.com/transmissions11/solmate diff --git a/README.md b/README.md index 4e0da581..2322db2f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ contract CoolHook is BaseHook { address, IPoolManager.PoolKey calldata key, IPoolManager.ModifyLiquidityParams calldata params - ) external override poolManagerOnly returns (bytes4) { + ) external override onlyByManager returns (bytes4) { // hook logic return BaseHook.beforeAddLiquidity.selector; } diff --git a/lib/forge-gas-snapshot b/lib/forge-gas-snapshot deleted file mode 160000 index 9161f7c0..00000000 --- a/lib/forge-gas-snapshot +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9161f7c0b6c6788a89081e2b3b9c67592b71e689 diff --git a/lib/forge-std b/lib/forge-std deleted file mode 160000 index 3d8086d4..00000000 --- a/lib/forge-std +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3d8086d4911b36c1874531ce8c367e6cfd028e80 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts deleted file mode 160000 index 5ae63068..00000000 --- a/lib/openzeppelin-contracts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5ae630684a0f57de400ef69499addab4c32ac8fb diff --git a/lib/solmate b/lib/solmate deleted file mode 160000 index bfc9c258..00000000 --- a/lib/solmate +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfc9c25865a274a7827fea5abf6e4fb64fc64e6c diff --git a/remappings.txt b/remappings.txt index e05c5bd6..11b1a65e 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,4 +1,2 @@ @uniswap/v4-core/=lib/v4-core/ -solmate/=lib/solmate/src/ -forge-std/=lib/forge-std/src/ -@openzeppelin/=lib/openzeppelin-contracts/ +@openzeppelin/=lib/v4-core/lib/openzeppelin-contracts/ diff --git a/src/PositionManager.sol b/src/PositionManager.sol new file mode 100644 index 00000000..c42e6558 --- /dev/null +++ b/src/PositionManager.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +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, 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 {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {TransientStateLibrary} from "@uniswap/v4-core/src/libraries/TransientStateLibrary.sol"; + +import {ERC721Permit} from "./base/ERC721Permit.sol"; +import {IPositionManager, Actions} from "./interfaces/IPositionManager.sol"; +import {SafeCallback} from "./base/SafeCallback.sol"; +import {Multicall} from "./base/Multicall.sol"; +import {PoolInitializer} from "./base/PoolInitializer.sol"; +import {CurrencySettleTake} from "./libraries/CurrencySettleTake.sol"; +import {LiquidityRange} from "./types/LiquidityRange.sol"; + +contract PositionManager is IPositionManager, ERC721Permit, PoolInitializer, Multicall, SafeCallback { + using CurrencyLibrary for Currency; + using CurrencySettleTake for Currency; + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + using TransientStateLibrary for IPoolManager; + using SafeCast for uint256; + + /// @dev The ID of the next token that will be minted. Skips 0 + uint256 public nextTokenId = 1; + + // maps the ERC721 tokenId to its Range (poolKey, tick range) + mapping(uint256 tokenId => LiquidityRange range) public tokenRange; + + constructor(IPoolManager _poolManager) + SafeCallback(_poolManager) + ERC721Permit("Uniswap V4 Positions NFT", "UNI-V4-POSM", "1") + {} + + modifier checkDeadline(uint256 deadline) { + if (block.timestamp > deadline) revert DeadlinePassed(); + _; + } + + /// @param unlockData is an encoding of actions, params, and currencies + /// @return returnData is the endocing of each actions return information + function modifyLiquidities(bytes calldata unlockData, uint256 deadline) + external + checkDeadline(deadline) + returns (bytes[] memory) + { + // TODO: Edit the encoding/decoding. + return abi.decode(poolManager.unlock(abi.encode(unlockData, msg.sender)), (bytes[])); + } + + function _unlockCallback(bytes calldata payload) internal override returns (bytes memory) { + // TODO: Fix double encode/decode + (bytes memory unlockData, address sender) = abi.decode(payload, (bytes, address)); + + (Actions[] memory actions, bytes[] memory params) = abi.decode(unlockData, (Actions[], bytes[])); + + bytes[] memory returnData = _dispatch(actions, params, sender); + + return abi.encode(returnData); + } + + function _dispatch(Actions[] memory actions, bytes[] memory params, address sender) + internal + returns (bytes[] memory returnData) + { + uint256 length = actions.length; + if (length != params.length) revert MismatchedLengths(); + returnData = new bytes[](length); + for (uint256 i; i < length; i++) { + if (actions[i] == Actions.INCREASE) { + returnData[i] = _increase(params[i], sender); + } else if (actions[i] == Actions.DECREASE) { + returnData[i] = _decrease(params[i], sender); + } else if (actions[i] == Actions.MINT) { + // TODO: Mint will be coupled with increase. + returnData[i] = _mint(params[i]); + } else if (actions[i] == Actions.CLOSE_CURRENCY) { + returnData[i] = _close(params[i], sender); + } else if (actions[i] == Actions.BURN) { + // TODO: Burn will be coupled with decrease. + (uint256 tokenId) = abi.decode(params[i], (uint256)); + burn(tokenId, sender); + } else { + revert UnsupportedAction(); + } + } + } + + /// @param param is an encoding of uint256 tokenId, uint256 liquidity, bytes hookData + /// @param sender the msg.sender, set by the `modifyLiquidities` function before the `unlockCallback`. Using msg.sender directly inside + /// the _unlockCallback will be the pool manager. + /// @return returns an encoding of the BalanceDelta applied by this increase call, including credited fees. + /// @dev Calling increase with 0 liquidity will credit the caller with any underlying fees of the position + function _increase(bytes memory param, address sender) internal returns (bytes memory) { + (uint256 tokenId, uint256 liquidity, bytes memory hookData) = abi.decode(param, (uint256, uint256, bytes)); + + _requireApprovedOrOwner(tokenId, sender); + + // Note: The tokenId is used as the salt for this position, so every minted liquidity has unique storage in the pool manager. + (BalanceDelta delta,) = _modifyLiquidity(tokenRange[tokenId], liquidity.toInt256(), bytes32(tokenId), hookData); + return abi.encode(delta); + } + + /// @param params is an encoding of uint256 tokenId, uint256 liquidity, bytes hookData + /// @param sender the msg.sender, set by the `modifyLiquidities` function before the `unlockCallback`. Using msg.sender directly inside + /// the _unlockCallback will be the pool manager. + /// @return returns an encoding of the BalanceDelta applied by this increase call, including credited fees. + /// @dev Calling decrease with 0 liquidity will credit the caller with any underlying fees of the position + function _decrease(bytes memory params, address sender) internal returns (bytes memory) { + (uint256 tokenId, uint256 liquidity, bytes memory hookData) = abi.decode(params, (uint256, uint256, bytes)); + + _requireApprovedOrOwner(tokenId, sender); + + // Note: the tokenId is used as the salt. + (BalanceDelta delta,) = + _modifyLiquidity(tokenRange[tokenId], -(liquidity.toInt256()), bytes32(tokenId), hookData); + return abi.encode(delta); + } + + /// @param param is an encoding of LiquidityRange memory range, uint256 liquidity, address recipient, bytes hookData where recipient is the receiver / owner of the ERC721 + /// @return returns an encoding of the BalanceDelta from the initial increase + function _mint(bytes memory param) internal returns (bytes memory) { + (LiquidityRange memory range, uint256 liquidity, address owner, bytes memory hookData) = + abi.decode(param, (LiquidityRange, uint256, address, bytes)); + + // mint receipt token + uint256 tokenId; + // tokenId is assigned to current nextTokenId before incrementing it + unchecked { + tokenId = nextTokenId++; + } + _mint(owner, tokenId); + + (BalanceDelta delta,) = _modifyLiquidity(range, liquidity.toInt256(), bytes32(tokenId), hookData); + + tokenRange[tokenId] = range; + + return abi.encode(delta); + } + + /// @param params is an encoding of the Currency to close + /// @param sender is the msg.sender encoded by the `modifyLiquidities` function before the `unlockCallback`. + /// @return an encoding of int256 the balance of the currency being settled by this call + function _close(bytes memory params, address sender) internal returns (bytes memory) { + (Currency currency) = abi.decode(params, (Currency)); + // this address has applied all deltas on behalf of the user/owner + // it is safe to close this entire delta because of slippage checks throughout the batched calls. + int256 currencyDelta = poolManager.currencyDelta(address(this), currency); + + // the sender is the payer or receiver + if (currencyDelta < 0) { + currency.settle(poolManager, sender, uint256(-int256(currencyDelta)), false); + } else if (currencyDelta > 0) { + currency.take(poolManager, sender, uint256(int256(currencyDelta)), false); + } + + return abi.encode(currencyDelta); + } + + function burn(uint256 tokenId, address sender) internal { + _requireApprovedOrOwner(tokenId, sender); + + // Checks that the full position's liquidity has been removed and all tokens have been collected from tokensOwed. + _validateBurn(tokenId); + + delete tokenRange[tokenId]; + // Burn the token. + _burn(tokenId); + } + + function _modifyLiquidity(LiquidityRange memory range, int256 liquidityChange, bytes32 salt, bytes memory hookData) + internal + returns (BalanceDelta liquidityDelta, BalanceDelta totalFeesAccrued) + { + (liquidityDelta, totalFeesAccrued) = poolManager.modifyLiquidity( + range.poolKey, + IPoolManager.ModifyLiquidityParams({ + tickLower: range.tickLower, + tickUpper: range.tickUpper, + liquidityDelta: liquidityChange, + salt: salt + }), + hookData + ); + } + + // ensures liquidity of the position is empty before burning the token. + function _validateBurn(uint256 tokenId) internal view { + bytes32 positionId = getPositionIdFromTokenId(tokenId); + uint128 liquidity = poolManager.getPositionLiquidity(tokenRange[tokenId].poolKey.toId(), positionId); + if (liquidity > 0) revert PositionMustBeEmpty(); + } + + // TODO: Move this to a posm state-view library. + function getPositionIdFromTokenId(uint256 tokenId) public view returns (bytes32 positionId) { + LiquidityRange memory range = tokenRange[tokenId]; + bytes32 salt = bytes32(tokenId); + int24 tickLower = range.tickLower; + int24 tickUpper = range.tickUpper; + address owner = address(this); + + // positionId = keccak256(abi.encodePacked(owner, tickLower, tickUpper, salt)) + assembly { + mstore(0x26, salt) // [0x26, 0x46) + mstore(0x06, tickUpper) // [0x23, 0x26) + mstore(0x03, tickLower) // [0x20, 0x23) + mstore(0, owner) // [0x0c, 0x20) + positionId := keccak256(0x0c, 0x3a) // len is 58 bytes + mstore(0x26, 0) // rewrite 0x26 to 0 + } + } + + function _requireApprovedOrOwner(uint256 tokenId, address sender) internal view { + if (!_isApprovedOrOwner(sender, tokenId)) revert NotApproved(sender); + } +} diff --git a/src/base/ERC721Permit.sol b/src/base/ERC721Permit.sol new file mode 100644 index 00000000..197b69c6 --- /dev/null +++ b/src/base/ERC721Permit.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.24; + +import {ERC721} from "solmate/tokens/ERC721.sol"; + +/// @notice An ERC721 contract that supports permit. +/// TODO: Support permit. +contract ERC721Permit is ERC721 { + constructor(string memory name_, string memory symbol_, string memory version_) ERC721(name_, symbol_) {} + + function _isApprovedOrOwner(address spender, uint256 tokenId) internal view returns (bool) { + return spender == ownerOf(tokenId) || getApproved[tokenId] == spender + || isApprovedForAll[ownerOf(tokenId)][spender]; + } + + // TODO: Use PositionDescriptor. + function tokenURI(uint256 id) public pure override returns (string memory) { + return string(abi.encode(id)); + } +} diff --git a/src/base/PoolInitializer.sol b/src/base/PoolInitializer.sol new file mode 100644 index 00000000..bb75a3d9 --- /dev/null +++ b/src/base/PoolInitializer.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.24; + +import {ImmutableState} from "./ImmutableState.sol"; + +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; + +abstract contract PoolInitializer is ImmutableState { + function initializePool(PoolKey calldata key, uint160 sqrtPriceX96, bytes calldata hookData) + external + returns (int24) + { + return poolManager.initialize(key, sqrtPriceX96, hookData); + } +} diff --git a/src/base/SelfPermit.sol b/src/base/SelfPermit.sol deleted file mode 100644 index 40449636..00000000 --- a/src/base/SelfPermit.sol +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.19; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; - -import {IERC20PermitAllowed} from "../interfaces/external/IERC20PermitAllowed.sol"; -import {ISelfPermit} from "../interfaces/ISelfPermit.sol"; - -/// @title Self Permit -/// @notice Functionality to call permit on any EIP-2612-compliant token for use in the route -/// @dev These functions are expected to be embedded in multicalls to allow EOAs to approve a contract and call a function -/// that requires an approval in a single transaction. -abstract contract SelfPermit is ISelfPermit { - /// @inheritdoc ISelfPermit - function selfPermit(address token, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) - public - payable - override - { - IERC20Permit(token).permit(msg.sender, address(this), value, deadline, v, r, s); - } - - /// @inheritdoc ISelfPermit - function selfPermitIfNecessary(address token, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) - external - payable - override - { - if (IERC20(token).allowance(msg.sender, address(this)) < value) selfPermit(token, value, deadline, v, r, s); - } - - /// @inheritdoc ISelfPermit - function selfPermitAllowed(address token, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) - public - payable - override - { - IERC20PermitAllowed(token).permit(msg.sender, address(this), nonce, expiry, true, v, r, s); - } - - /// @inheritdoc ISelfPermit - function selfPermitAllowedIfNecessary(address token, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) - external - payable - override - { - if (IERC20(token).allowance(msg.sender, address(this)) < type(uint256).max) { - selfPermitAllowed(token, nonce, expiry, v, r, s); - } - } -} diff --git a/src/interfaces/IERC721Permit.sol b/src/interfaces/IERC721Permit.sol new file mode 100644 index 00000000..213bca2a --- /dev/null +++ b/src/interfaces/IERC721Permit.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; + +/// @title ERC721 with permit +/// @notice Extension to ERC721 that includes a permit function for signature based approvals +interface IERC721Permit { + error NonceAlreadyUsed(); + + /// @notice The permit typehash used in the permit signature + /// @return The typehash for the permit + function PERMIT_TYPEHASH() external pure returns (bytes32); + + /// @notice The domain separator used in the permit signature + /// @return The domain seperator used in encoding of permit signature + function DOMAIN_SEPARATOR() external view returns (bytes32); + + /// @notice Approve of a specific token ID for spending by spender via signature + /// @param spender The account that is being approved + /// @param tokenId The ID of the token that is being approved for spending + /// @param deadline The deadline timestamp by which the call must be mined for the approve to work + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function permit(address spender, uint256 tokenId, uint256 deadline, uint256 nonce, uint8 v, bytes32 r, bytes32 s) + external + payable; +} diff --git a/src/interfaces/IPositionManager.sol b/src/interfaces/IPositionManager.sol new file mode 100644 index 00000000..c47929a4 --- /dev/null +++ b/src/interfaces/IPositionManager.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; + +enum Actions { + MINT, + BURN, + INCREASE, + DECREASE, + // Any positive delta on a currency will be sent to specified address + CLOSE_CURRENCY +} + +interface IPositionManager { + error MismatchedLengths(); + error NotApproved(address caller); + error DeadlinePassed(); + error UnsupportedAction(); + error PositionMustBeEmpty(); + + // TODO: This will just return a positionId + function tokenRange(uint256 tokenId) + external + view + returns (PoolKey memory poolKey, int24 tickLower, int24 tickUpper); + + /// @notice Batches many liquidity modification calls to pool manager + /// @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 nextTokenId() external view returns (uint256); +} diff --git a/src/interfaces/IQuoter.sol b/src/interfaces/IQuoter.sol index 8774e548..3aed2ac2 100644 --- a/src/interfaces/IQuoter.sol +++ b/src/interfaces/IQuoter.sol @@ -11,7 +11,6 @@ import {PathKey} from "../libraries/PathKey.sol"; /// @dev These functions are not marked view because they rely on calling non-view functions and reverting /// to compute the result. They are also not gas efficient and should not be called on-chain. interface IQuoter { - error InvalidUnlockCallbackSender(); error InvalidLockCaller(); error InvalidQuoteBatchParams(); error InsufficientAmountOut(); diff --git a/src/interfaces/ISelfPermit.sol b/src/interfaces/ISelfPermit.sol deleted file mode 100644 index cb2445f5..00000000 --- a/src/interfaces/ISelfPermit.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.5.0; - -/// @title Self Permit -/// @notice Functionality to call permit on any EIP-2612-compliant token for use in the route -interface ISelfPermit { - /// @notice Permits this contract to spend a given token from `msg.sender` - /// @dev The `owner` is always msg.sender and the `spender` is always address(this). - /// @param token The address of the token spent - /// @param value The amount that can be spent of token - /// @param deadline A timestamp, the current blocktime must be less than or equal to this timestamp - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function selfPermit(address token, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) - external - payable; - - /// @notice Permits this contract to spend a given token from `msg.sender` - /// @dev The `owner` is always msg.sender and the `spender` is always address(this). - /// Can be used instead of #selfPermit to prevent calls from failing due to a frontrun of a call to #selfPermit - /// @param token The address of the token spent - /// @param value The amount that can be spent of token - /// @param deadline A timestamp, the current blocktime must be less than or equal to this timestamp - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function selfPermitIfNecessary(address token, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) - external - payable; - - /// @notice Permits this contract to spend the sender's tokens for permit signatures that have the `allowed` parameter - /// @dev The `owner` is always msg.sender and the `spender` is always address(this) - /// @param token The address of the token spent - /// @param nonce The current nonce of the owner - /// @param expiry The timestamp at which the permit is no longer valid - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function selfPermitAllowed(address token, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) - external - payable; - - /// @notice Permits this contract to spend the sender's tokens for permit signatures that have the `allowed` parameter - /// @dev The `owner` is always msg.sender and the `spender` is always address(this) - /// Can be used instead of #selfPermitAllowed to prevent calls from failing due to a frontrun of a call to #selfPermitAllowed. - /// @param token The address of the token spent - /// @param nonce The current nonce of the owner - /// @param expiry The timestamp at which the permit is no longer valid - /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` - /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` - /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` - function selfPermitAllowedIfNecessary(address token, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) - external - payable; -} diff --git a/src/lens/Quoter.sol b/src/lens/Quoter.sol index 9e9bfda2..dd1cc7d3 100644 --- a/src/lens/Quoter.sol +++ b/src/lens/Quoter.sol @@ -14,8 +14,9 @@ import {IQuoter} from "../interfaces/IQuoter.sol"; import {PoolTicksCounter} from "../libraries/PoolTicksCounter.sol"; import {PathKey, PathKeyLib} from "../libraries/PathKey.sol"; import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {SafeCallback} from "../base/SafeCallback.sol"; -contract Quoter is IQuoter, IUnlockCallback { +contract Quoter is IQuoter, SafeCallback { using Hooks for IHooks; using PoolIdLibrary for PoolKey; using PathKeyLib for PathKey; @@ -24,9 +25,6 @@ contract Quoter is IQuoter, IUnlockCallback { /// @dev cache used to check a safety condition in exact output swaps. uint128 private amountOutCached; - // v4 Singleton contract - IPoolManager public immutable manager; - /// @dev min valid reason is 3-words long /// @dev int128[2] + sqrtPriceX96After padded to 32bytes + intializeTicksLoaded padded to 32bytes uint256 internal constant MINIMUM_VALID_RESPONSE_LENGTH = 96; @@ -54,9 +52,7 @@ contract Quoter is IQuoter, IUnlockCallback { _; } - constructor(address _poolManager) { - manager = IPoolManager(_poolManager); - } + constructor(IPoolManager _poolManager) SafeCallback(_poolManager) {} /// @inheritdoc IQuoter function quoteExactInputSingle(QuoteExactSingleParams memory params) @@ -64,7 +60,7 @@ contract Quoter is IQuoter, IUnlockCallback { override returns (int128[] memory deltaAmounts, uint160 sqrtPriceX96After, uint32 initializedTicksLoaded) { - try manager.unlock(abi.encodeWithSelector(this._quoteExactInputSingle.selector, params)) {} + try poolManager.unlock(abi.encodeWithSelector(this._quoteExactInputSingle.selector, params)) {} catch (bytes memory reason) { return _handleRevertSingle(reason); } @@ -79,7 +75,7 @@ contract Quoter is IQuoter, IUnlockCallback { uint32[] memory initializedTicksLoadedList ) { - try manager.unlock(abi.encodeWithSelector(this._quoteExactInput.selector, params)) {} + try poolManager.unlock(abi.encodeWithSelector(this._quoteExactInput.selector, params)) {} catch (bytes memory reason) { return _handleRevert(reason); } @@ -91,7 +87,7 @@ contract Quoter is IQuoter, IUnlockCallback { override returns (int128[] memory deltaAmounts, uint160 sqrtPriceX96After, uint32 initializedTicksLoaded) { - try manager.unlock(abi.encodeWithSelector(this._quoteExactOutputSingle.selector, params)) {} + try poolManager.unlock(abi.encodeWithSelector(this._quoteExactOutputSingle.selector, params)) {} catch (bytes memory reason) { if (params.sqrtPriceLimitX96 == 0) delete amountOutCached; return _handleRevertSingle(reason); @@ -108,18 +104,13 @@ contract Quoter is IQuoter, IUnlockCallback { uint32[] memory initializedTicksLoadedList ) { - try manager.unlock(abi.encodeWithSelector(this._quoteExactOutput.selector, params)) {} + try poolManager.unlock(abi.encodeWithSelector(this._quoteExactOutput.selector, params)) {} catch (bytes memory reason) { return _handleRevert(reason); } } - /// @inheritdoc IUnlockCallback - function unlockCallback(bytes calldata data) external returns (bytes memory) { - if (msg.sender != address(manager)) { - revert InvalidUnlockCallbackSender(); - } - + function _unlockCallback(bytes calldata data) internal override returns (bytes memory) { (bool success, bytes memory returnData) = address(this).call(data); if (success) return returnData; if (returnData.length == 0) revert LockFailure(); @@ -177,7 +168,7 @@ contract Quoter is IQuoter, IUnlockCallback { for (uint256 i = 0; i < pathLength; i++) { (PoolKey memory poolKey, bool zeroForOne) = params.path[i].getPoolAndSwapDirection(i == 0 ? params.exactCurrency : cache.prevCurrency); - (, cache.tickBefore,,) = manager.getSlot0(poolKey.toId()); + (, cache.tickBefore,,) = poolManager.getSlot0(poolKey.toId()); (cache.curDeltas, cache.sqrtPriceX96After, cache.tickAfter) = _swap( poolKey, @@ -197,7 +188,7 @@ contract Quoter is IQuoter, IUnlockCallback { cache.prevCurrency = params.path[i].intermediateCurrency; result.sqrtPriceX96AfterList[i] = cache.sqrtPriceX96After; result.initializedTicksLoadedList[i] = - PoolTicksCounter.countInitializedTicksLoaded(manager, poolKey, cache.tickBefore, cache.tickAfter); + PoolTicksCounter.countInitializedTicksLoaded(poolManager, poolKey, cache.tickBefore, cache.tickAfter); } bytes memory r = abi.encode(result.deltaAmounts, result.sqrtPriceX96AfterList, result.initializedTicksLoadedList); @@ -208,7 +199,7 @@ contract Quoter is IQuoter, IUnlockCallback { /// @dev quote an ExactInput swap on a pool, then revert with the result function _quoteExactInputSingle(QuoteExactSingleParams memory params) public selfOnly returns (bytes memory) { - (, int24 tickBefore,,) = manager.getSlot0(params.poolKey.toId()); + (, int24 tickBefore,,) = poolManager.getSlot0(params.poolKey.toId()); (BalanceDelta deltas, uint160 sqrtPriceX96After, int24 tickAfter) = _swap( params.poolKey, @@ -224,7 +215,7 @@ contract Quoter is IQuoter, IUnlockCallback { deltaAmounts[1] = -deltas.amount1(); uint32 initializedTicksLoaded = - PoolTicksCounter.countInitializedTicksLoaded(manager, params.poolKey, tickBefore, tickAfter); + PoolTicksCounter.countInitializedTicksLoaded(poolManager, params.poolKey, tickBefore, tickAfter); bytes memory result = abi.encode(deltaAmounts, sqrtPriceX96After, initializedTicksLoaded); assembly { revert(add(0x20, result), mload(result)) @@ -251,7 +242,7 @@ contract Quoter is IQuoter, IUnlockCallback { params.path[i - 1], i == pathLength ? params.exactCurrency : cache.prevCurrency ); - (, cache.tickBefore,,) = manager.getSlot0(poolKey.toId()); + (, cache.tickBefore,,) = poolManager.getSlot0(poolKey.toId()); (cache.curDeltas, cache.sqrtPriceX96After, cache.tickAfter) = _swap(poolKey, !oneForZero, int256(uint256(curAmountOut)), 0, params.path[i - 1].hookData); @@ -268,7 +259,7 @@ contract Quoter is IQuoter, IUnlockCallback { cache.prevCurrency = params.path[i - 1].intermediateCurrency; result.sqrtPriceX96AfterList[i - 1] = cache.sqrtPriceX96After; result.initializedTicksLoadedList[i - 1] = - PoolTicksCounter.countInitializedTicksLoaded(manager, poolKey, cache.tickBefore, cache.tickAfter); + PoolTicksCounter.countInitializedTicksLoaded(poolManager, poolKey, cache.tickBefore, cache.tickAfter); } bytes memory r = abi.encode(result.deltaAmounts, result.sqrtPriceX96AfterList, result.initializedTicksLoadedList); @@ -282,7 +273,7 @@ contract Quoter is IQuoter, IUnlockCallback { // if no price limit has been specified, cache the output amount for comparison in the swap callback if (params.sqrtPriceLimitX96 == 0) amountOutCached = params.exactAmount; - (, int24 tickBefore,,) = manager.getSlot0(params.poolKey.toId()); + (, int24 tickBefore,,) = poolManager.getSlot0(params.poolKey.toId()); (BalanceDelta deltas, uint160 sqrtPriceX96After, int24 tickAfter) = _swap( params.poolKey, params.zeroForOne, @@ -298,7 +289,7 @@ contract Quoter is IQuoter, IUnlockCallback { deltaAmounts[1] = -deltas.amount1(); uint32 initializedTicksLoaded = - PoolTicksCounter.countInitializedTicksLoaded(manager, params.poolKey, tickBefore, tickAfter); + PoolTicksCounter.countInitializedTicksLoaded(poolManager, params.poolKey, tickBefore, tickAfter); bytes memory result = abi.encode(deltaAmounts, sqrtPriceX96After, initializedTicksLoaded); assembly { revert(add(0x20, result), mload(result)) @@ -314,7 +305,7 @@ contract Quoter is IQuoter, IUnlockCallback { uint160 sqrtPriceLimitX96, bytes memory hookData ) private returns (BalanceDelta deltas, uint160 sqrtPriceX96After, int24 tickAfter) { - deltas = manager.swap( + deltas = poolManager.swap( poolKey, IPoolManager.SwapParams({ zeroForOne: zeroForOne, @@ -327,7 +318,7 @@ contract Quoter is IQuoter, IUnlockCallback { if (amountOutCached != 0 && amountOutCached != uint128(zeroForOne ? deltas.amount1() : deltas.amount0())) { revert InsufficientAmountOut(); } - (sqrtPriceX96After, tickAfter,,) = manager.getSlot0(poolKey.toId()); + (sqrtPriceX96After, tickAfter,,) = poolManager.getSlot0(poolKey.toId()); } /// @dev return either the sqrtPriceLimit from user input, or the max/min value possible depending on trade direction diff --git a/src/libraries/ChainId.sol b/src/libraries/ChainId.sol new file mode 100644 index 00000000..7e67989c --- /dev/null +++ b/src/libraries/ChainId.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.0; + +/// @title Function for getting the current chain ID +library ChainId { + /// @dev Gets the current chain ID + /// @return chainId The current chain ID + function get() internal view returns (uint256 chainId) { + assembly { + chainId := chainid() + } + } +} diff --git a/src/libraries/CurrencySettleTake.sol b/src/libraries/CurrencySettleTake.sol new file mode 100644 index 00000000..d504d30c --- /dev/null +++ b/src/libraries/CurrencySettleTake.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.24; + +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {IERC20Minimal} from "v4-core/interfaces/external/IERC20Minimal.sol"; + +/// @notice Library used to interact with PoolManager.sol to settle any open deltas. +/// To settle a positive delta (a credit to the user), a user may take or mint. +/// To settle a negative delta (a debt on the user), a user make transfer or burn to pay off a debt. +/// @dev Note that sync() is called before any erc-20 transfer in `settle`. +library CurrencySettleTake { + /// @notice Settle (pay) a currency to the PoolManager + /// @param currency Currency to settle + /// @param manager IPoolManager to settle to + /// @param payer Address of the payer, the token sender + /// @param amount Amount to send + /// @param burn If true, burn the ERC-6909 token, otherwise ERC20-transfer to the PoolManager + function settle(Currency currency, IPoolManager manager, address payer, uint256 amount, bool burn) internal { + // for native currencies or burns, calling sync is not required + // short circuit for ERC-6909 burns to support ERC-6909-wrapped native tokens + if (burn) { + manager.burn(payer, currency.toId(), amount); + } else if (currency.isNative()) { + manager.settle{value: amount}(); + } else { + manager.sync(currency); + if (payer != address(this)) { + IERC20Minimal(Currency.unwrap(currency)).transferFrom(payer, address(manager), amount); + } else { + IERC20Minimal(Currency.unwrap(currency)).transfer(address(manager), amount); + } + manager.settle(); + } + } + + /// @notice Take (receive) a currency from the PoolManager + /// @param currency Currency to take + /// @param manager IPoolManager to take from + /// @param recipient Address of the recipient, the token receiver + /// @param amount Amount to receive + /// @param claims If true, mint the ERC-6909 token, otherwise ERC20-transfer from the PoolManager to recipient + function take(Currency currency, IPoolManager manager, address recipient, uint256 amount, bool claims) internal { + claims ? manager.mint(recipient, currency.toId(), amount) : manager.take(currency, recipient, amount); + } +} diff --git a/src/types/LiquidityRange.sol b/src/types/LiquidityRange.sol new file mode 100644 index 00000000..4f664027 --- /dev/null +++ b/src/types/LiquidityRange.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.24; + +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; + +struct LiquidityRange { + PoolKey poolKey; + int24 tickLower; + int24 tickUpper; +} + +type LiquidityRangeId is bytes32; + +/// @notice Library for computing the ID of a liquidity range +library LiquidityRangeIdLibrary { + function toId(LiquidityRange memory position) internal pure returns (LiquidityRangeId) { + // TODO: gas, is it better to encodePacked? + return LiquidityRangeId.wrap(keccak256(abi.encode(position))); + } +} diff --git a/test/Quoter.t.sol b/test/Quoter.t.sol index c0fa9497..b16f721c 100644 --- a/test/Quoter.t.sol +++ b/test/Quoter.t.sol @@ -33,8 +33,8 @@ contract QuoterTest is Test, Deployers { // Max tick for full range with tick spacing of 60 int24 internal constant MAX_TICK = -MIN_TICK; - uint160 internal constant SQRT_RATIO_100_102 = 78447570448055484695608110440; - uint160 internal constant SQRT_RATIO_102_100 = 80016521857016594389520272648; + uint160 internal constant SQRT_PRICE_100_102 = 78447570448055484695608110440; + uint160 internal constant SQRT_PRICE_102_100 = 80016521857016594389520272648; uint256 internal constant CONTROLLER_GAS_LIMIT = 500000; @@ -54,7 +54,7 @@ contract QuoterTest is Test, Deployers { function setUp() public { deployFreshManagerAndRouters(); - quoter = new Quoter(address(manager)); + quoter = new Quoter(IPoolManager(manager)); positionManager = new PoolModifyLiquidityTest(manager); // salts are chosen so that address(token0) < address(token1) && address(token1) < address(token2) @@ -329,13 +329,13 @@ contract QuoterTest is Test, Deployers { zeroForOne: true, recipient: address(this), exactAmount: type(uint128).max, - sqrtPriceLimitX96: SQRT_RATIO_100_102, + sqrtPriceLimitX96: SQRT_PRICE_100_102, hookData: ZERO_BYTES }) ); assertEq(deltaAmounts[0], 9981); - assertEq(sqrtPriceX96After, SQRT_RATIO_100_102); + assertEq(sqrtPriceX96After, SQRT_PRICE_100_102); assertEq(initializedTicksLoaded, 0); } @@ -347,13 +347,13 @@ contract QuoterTest is Test, Deployers { zeroForOne: false, recipient: address(this), exactAmount: type(uint128).max, - sqrtPriceLimitX96: SQRT_RATIO_102_100, + sqrtPriceLimitX96: SQRT_PRICE_102_100, hookData: ZERO_BYTES }) ); assertEq(deltaAmounts[1], 9981); - assertEq(sqrtPriceX96After, SQRT_RATIO_102_100); + assertEq(sqrtPriceX96After, SQRT_PRICE_102_100); assertEq(initializedTicksLoaded, 0); } diff --git a/test/base/Multicall.t.sol b/test/base/Multicall.t.sol new file mode 100644 index 00000000..dc90be4b --- /dev/null +++ b/test/base/Multicall.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import {MockMulticall} from "../mock/MockMulticall.sol"; + +contract MulticallTest is Test { + MockMulticall multicall; + + function setUp() public { + multicall = new MockMulticall(); + } + + function test_multicall() public { + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSelector(MockMulticall(multicall).functionThatReturnsTuple.selector, 10, 20); + calls[1] = abi.encodeWithSelector(MockMulticall(multicall).functionThatReturnsTuple.selector, 1, 2); + + bytes[] memory results = multicall.multicall(calls); + + (uint256 a, uint256 b) = abi.decode(results[0], (uint256, uint256)); + assertEq(a, 10); + assertEq(b, 20); + + (a, b) = abi.decode(results[1], (uint256, uint256)); + assertEq(a, 1); + assertEq(b, 2); + } + + function test_multicall_firstRevert() public { + bytes[] memory calls = new bytes[](2); + calls[0] = + abi.encodeWithSelector(MockMulticall(multicall).functionThatRevertsWithError.selector, "First call failed"); + calls[1] = abi.encodeWithSelector(MockMulticall(multicall).functionThatReturnsTuple.selector, 1, 2); + + vm.expectRevert("First call failed"); + multicall.multicall(calls); + } + + function test_multicall_secondRevert() public { + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSelector(MockMulticall(multicall).functionThatReturnsTuple.selector, 1, 2); + calls[1] = + abi.encodeWithSelector(MockMulticall(multicall).functionThatRevertsWithError.selector, "Second call failed"); + + vm.expectRevert("Second call failed"); + multicall.multicall(calls); + } + + function test_multicall_payableStoresMsgValue() public { + assertEq(address(multicall).balance, 0); + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeWithSelector(MockMulticall(multicall).payableStoresMsgValue.selector); + multicall.multicall{value: 100}(calls); + assertEq(address(multicall).balance, 100); + assertEq(multicall.msgValue(), 100); + } + + function test_multicall_returnSender() public { + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeWithSelector(MockMulticall(multicall).returnSender.selector); + bytes[] memory results = multicall.multicall(calls); + address sender = abi.decode(results[0], (address)); + assertEq(sender, address(this)); + } + + function test_multicall_returnSender_prank() public { + address alice = makeAddr("ALICE"); + + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeWithSelector(MockMulticall(multicall).returnSender.selector, alice); + vm.prank(alice); + bytes[] memory results = multicall.multicall(calls); + address sender = abi.decode(results[0], (address)); + assertEq(sender, alice); + } + + function test_multicall_double_send() public { + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSelector(MockMulticall(multicall).payableStoresMsgValue.selector); + calls[1] = abi.encodeWithSelector(MockMulticall(multicall).payableStoresMsgValue.selector); + + multicall.multicall{value: 100}(calls); + assertEq(address(multicall).balance, 100); + assertEq(multicall.msgValue(), 100); + } + + function test_multicall_unpayableRevert() public { + // first call is payable, second is not which causes a revert + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSelector(MockMulticall(multicall).payableStoresMsgValue.selector); + calls[1] = abi.encodeWithSelector(MockMulticall(multicall).functionThatReturnsTuple.selector, 10, 20); + + vm.expectRevert(); + multicall.multicall{value: 100}(calls); + } + + function test_multicall_bothPayable() public { + // msg.value is provided to both calls + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSelector(MockMulticall(multicall).payableStoresMsgValue.selector); + calls[1] = abi.encodeWithSelector(MockMulticall(multicall).payableStoresMsgValueDouble.selector); + + multicall.multicall{value: 100}(calls); + assertEq(address(multicall).balance, 100); + assertEq(multicall.msgValue(), 100); + assertEq(multicall.msgValueDouble(), 200); + } +} diff --git a/test/mock/MockMulticall.sol b/test/mock/MockMulticall.sol new file mode 100644 index 00000000..4b96d915 --- /dev/null +++ b/test/mock/MockMulticall.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.20; + +import "../../src/base/Multicall.sol"; + +contract MockMulticall is Multicall { + struct Tuple { + uint256 a; + uint256 b; + } + + uint256 public msgValue; + uint256 public msgValueDouble; + + function functionThatRevertsWithError(string memory error) external pure { + revert(error); + } + + function functionThatReturnsTuple(uint256 a, uint256 b) external pure returns (Tuple memory tuple) { + tuple = Tuple({a: a, b: b}); + } + + function payableStoresMsgValue() external payable { + msgValue = msg.value; + } + + function payableStoresMsgValueDouble() external payable { + msgValueDouble = 2 * msg.value; + } + + function returnSender() external view returns (address) { + return msg.sender; + } +} diff --git a/test/position-managers/Execute.t.sol b/test/position-managers/Execute.t.sol new file mode 100644 index 00000000..06f65cd8 --- /dev/null +++ b/test/position-managers/Execute.t.sol @@ -0,0 +1,146 @@ +// 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 {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} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +import {IPositionManager, Actions} from "../../src/interfaces/IPositionManager.sol"; +import {PositionManager} from "../../src/PositionManager.sol"; +import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; +import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; +import {Planner} from "../utils/Planner.sol"; +import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; + +contract ExecuteTest is Test, PosmTestSetup, LiquidityFuzzers { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using PoolIdLibrary for PoolKey; + using Planner for Planner.Plan; + using StateLibrary for IPoolManager; + + PoolId poolId; + address alice = makeAddr("ALICE"); + address bob = makeAddr("BOB"); + + LiquidityRange range; + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + + // Requires currency0 and currency1 to be set in base Deployers contract. + deployAndApprovePosm(manager); + + // Give tokens to Alice and Bob. + seedBalance(alice); + seedBalance(bob); + + // Approve posm for Alice and bob. + approvePosmFor(alice); + approvePosmFor(bob); + + // define a reusable range + range = LiquidityRange({poolKey: key, tickLower: -300, tickUpper: 300}); + } + + function test_fuzz_execute_increaseLiquidity_once(uint256 initialLiquidity, uint256 liquidityToAdd) public { + initialLiquidity = bound(initialLiquidity, 1e18, 1000e18); + liquidityToAdd = bound(liquidityToAdd, 1e18, 1000e18); + mint(range, initialLiquidity, address(this), ZERO_BYTES); + uint256 tokenId = lpm.nextTokenId() - 1; + + increaseLiquidity(tokenId, liquidityToAdd, 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, initialLiquidity + liquidityToAdd); + } + + function test_fuzz_execute_increaseLiquidity_twice( + uint256 initialiLiquidity, + uint256 liquidityToAdd, + uint256 liquidityToAdd2 + ) public { + initialiLiquidity = bound(initialiLiquidity, 1e18, 1000e18); + liquidityToAdd = bound(liquidityToAdd, 1e18, 1000e18); + liquidityToAdd2 = bound(liquidityToAdd2, 1e18, 1000e18); + mint(range, initialiLiquidity, address(this), ZERO_BYTES); + uint256 tokenId = lpm.nextTokenId() - 1; + + Planner.Plan memory planner = Planner.init(); + + planner = planner.add(Actions.INCREASE, abi.encode(tokenId, liquidityToAdd, ZERO_BYTES)); + planner = planner.add(Actions.INCREASE, abi.encode(tokenId, liquidityToAdd2, ZERO_BYTES)); + + bytes memory calls = planner.finalize(range.poolKey); + lpm.modifyLiquidities(calls, _deadline); + + bytes32 positionId = + keccak256(abi.encodePacked(address(lpm), range.tickLower, range.tickUpper, bytes32(tokenId))); + (uint256 liquidity,,) = manager.getPositionInfo(range.poolKey.toId(), positionId); + + assertEq(liquidity, initialiLiquidity + liquidityToAdd + liquidityToAdd2); + } + + // this case doesnt make sense in real world usage, so it doesnt have a cool name. but its a good test case + function test_fuzz_execute_mintAndIncrease(uint256 initialLiquidity, uint256 liquidityToAdd) public { + initialLiquidity = bound(initialLiquidity, 1e18, 1000e18); + liquidityToAdd = bound(liquidityToAdd, 1e18, 1000e18); + + uint256 tokenId = lpm.nextTokenId(); // assume that the .mint() produces tokenId=1, to be used in increaseLiquidity + + Planner.Plan memory planner = Planner.init(); + + planner = planner.add(Actions.MINT, abi.encode(range, initialLiquidity, address(this), ZERO_BYTES)); + planner = planner.add(Actions.INCREASE, abi.encode(tokenId, liquidityToAdd, ZERO_BYTES)); + + bytes memory calls = planner.finalize(range.poolKey); + lpm.modifyLiquidities(calls, _deadline); + + bytes32 positionId = + keccak256(abi.encodePacked(address(lpm), range.tickLower, range.tickUpper, bytes32(tokenId))); + (uint256 liquidity,,) = manager.getPositionInfo(range.poolKey.toId(), positionId); + + assertEq(liquidity, initialLiquidity + liquidityToAdd); + } + + // rebalance: burn and mint + function test_execute_rebalance() public {} + // coalesce: burn and increase + function test_execute_coalesce() public {} + // split: decrease and mint + function test_execute_split() public {} + // shift: decrease and increase + function test_execute_shift() public {} + // shard: collect and mint + function test_execute_shard() public {} + // feed: collect and increase + function test_execute_feed() public {} + + // transplant: burn and mint on different keys + function test_execute_transplant() public {} + // cross-coalesce: burn and increase on different keys + function test_execute_crossCoalesce() public {} + // cross-split: decrease and mint on different keys + function test_execute_crossSplit() public {} + // cross-shift: decrease and increase on different keys + function test_execute_crossShift() public {} + // cross-shard: collect and mint on different keys + function test_execute_crossShard() public {} + // cross-feed: collect and increase on different keys + function test_execute_crossFeed() public {} +} diff --git a/test/position-managers/FeeCollection.t.sol b/test/position-managers/FeeCollection.t.sol new file mode 100644 index 00000000..5a31c766 --- /dev/null +++ b/test/position-managers/FeeCollection.t.sol @@ -0,0 +1,220 @@ +// 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 {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} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +import {PositionManager} from "../../src/PositionManager.sol"; +import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; +import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; +import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; +import {FeeMath} from "../shared/FeeMath.sol"; +import {IPositionManager} from "../../src/interfaces/IPositionManager.sol"; + +contract FeeCollectionTest is Test, PosmTestSetup, LiquidityFuzzers { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using FeeMath for IPositionManager; + + PoolId poolId; + address alice = makeAddr("ALICE"); + address bob = makeAddr("BOB"); + + // expresses the fee as a wad (i.e. 3000 = 0.003e18) + uint256 FEE_WAD; + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + (key, poolId) = initPool(currency0, 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. + deployAndApprovePosm(manager); + + // Give tokens to Alice and Bob. + seedBalance(alice); + seedBalance(bob); + + // Approve posm for Alice and bob. + approvePosmFor(alice); + approvePosmFor(bob); + } + + function test_fuzz_collect_erc20(IPoolManager.ModifyLiquidityParams memory params) public { + params.liquidityDelta = bound(params.liquidityDelta, 10e18, 10_000e18); + uint256 tokenId; + (tokenId, params) = addFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // require two-sided liquidity + + // swap to create fees + uint256 swapAmount = 0.01e18; + swap(key, false, -int256(swapAmount), ZERO_BYTES); + + BalanceDelta expectedFees = IPositionManager(address(lpm)).getFeesOwed(manager, tokenId); + + // collect fees + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + BalanceDelta delta = collect(tokenId, ZERO_BYTES); + + assertEq(uint256(int256(delta.amount1())), uint256(int256(expectedFees.amount1()))); + assertEq(uint256(int256(delta.amount0())), uint256(int256(expectedFees.amount0()))); + + assertEq(uint256(int256(delta.amount0())), currency0.balanceOfSelf() - balance0Before); + assertEq(uint256(int256(delta.amount1())), currency1.balanceOfSelf() - balance1Before); + } + + function test_fuzz_collect_sameRange_erc20( + IPoolManager.ModifyLiquidityParams memory params, + uint256 liquidityDeltaBob + ) public { + params.liquidityDelta = bound(params.liquidityDelta, 10e18, 10_000e18); + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // require two-sided liquidity + + liquidityDeltaBob = bound(liquidityDeltaBob, 100e18, 100_000e18); + + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + vm.prank(alice); + mint(range, uint256(params.liquidityDelta), alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + vm.prank(bob); + mint(range, liquidityDeltaBob, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // confirm the positions are same range + (, int24 tickLowerAlice, int24 tickUpperAlice) = lpm.tokenRange(tokenIdAlice); + (, int24 tickLowerBob, int24 tickUpperBob) = lpm.tokenRange(tokenIdBob); + assertEq(tickLowerAlice, tickLowerBob); + assertEq(tickUpperAlice, tickUpperBob); + + // swap to create fees + uint256 swapAmount = 0.01e18; + swap(key, false, -int256(swapAmount), ZERO_BYTES); + + // alice collects only her fees + uint256 balance0AliceBefore = currency0.balanceOf(alice); + uint256 balance1AliceBefore = currency1.balanceOf(alice); + vm.startPrank(alice); + BalanceDelta delta = collect(tokenIdAlice, ZERO_BYTES); + vm.stopPrank(); + uint256 balance0AliceAfter = currency0.balanceOf(alice); + uint256 balance1AliceAfter = currency1.balanceOf(alice); + + assertEq(balance0AliceBefore, balance0AliceAfter); + assertEq(uint256(uint128(delta.amount1())), balance1AliceAfter - balance1AliceBefore); + assertTrue(delta.amount1() != 0); + + // bob collects only his fees + uint256 balance0BobBefore = currency0.balanceOf(bob); + uint256 balance1BobBefore = currency1.balanceOf(bob); + vm.startPrank(bob); + delta = collect(tokenIdBob, ZERO_BYTES); + vm.stopPrank(); + uint256 balance0BobAfter = currency0.balanceOf(bob); + uint256 balance1BobAfter = currency1.balanceOf(bob); + + assertEq(balance0BobBefore, balance0BobAfter); + assertEq(uint256(uint128(delta.amount1())), balance1BobAfter - balance1BobBefore); + assertTrue(delta.amount1() != 0); + + // position manager should never hold fees + assertEq(manager.balanceOf(address(lpm), currency0.toId()), 0); + assertEq(manager.balanceOf(address(lpm), currency1.toId()), 0); + } + + /// @dev Alice and Bob create liquidity on the same range, and decrease their liquidity + // Even though their positions are the same range, they are unique positions in pool manager. + function test_decreaseLiquidity_sameRange_exact() public { + // alice and bob create liquidity on the same range [-120, 120] + LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: -120, tickUpper: 120}); + + // alice provisions 3x the amount of liquidity as bob + uint256 liquidityAlice = 3000e18; + uint256 liquidityBob = 1000e18; + + vm.startPrank(alice); + BalanceDelta lpDeltaAlice = mint(range, liquidityAlice, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + vm.stopPrank(); + + vm.startPrank(bob); + BalanceDelta lpDeltaBob = mint(range, liquidityBob, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + vm.stopPrank(); + + // swap to create fees + uint256 swapAmount = 0.001e18; + swap(key, true, -int256(swapAmount), ZERO_BYTES); // zeroForOne is true, so zero is the input + swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back, // zeroForOne is false, so one is the input + + uint256 tolerance = 0.000000001 ether; + + { + uint256 aliceBalance0Before = IERC20(Currency.unwrap(currency0)).balanceOf(address(alice)); + uint256 aliceBalance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(alice)); + // alice decreases liquidity + vm.startPrank(alice); + decreaseLiquidity(tokenIdAlice, liquidityAlice, ZERO_BYTES); + vm.stopPrank(); + + // alice has accrued her principle liquidity + any fees in token0 + assertApproxEqAbs( + IERC20(Currency.unwrap(currency0)).balanceOf(address(alice)) - aliceBalance0Before, + uint256(int256(-lpDeltaAlice.amount0())) + + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityAlice, liquidityAlice + liquidityBob), + tolerance + ); + // alice has accrued her principle liquidity + any fees in token1 + assertApproxEqAbs( + IERC20(Currency.unwrap(currency1)).balanceOf(address(alice)) - aliceBalance1Before, + uint256(int256(-lpDeltaAlice.amount1())) + + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityAlice, liquidityAlice + liquidityBob), + tolerance + ); + } + + { + uint256 bobBalance0Before = IERC20(Currency.unwrap(currency0)).balanceOf(address(bob)); + uint256 bobBalance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(bob)); + // bob decreases half of his liquidity + vm.startPrank(bob); + decreaseLiquidity(tokenIdBob, liquidityBob / 2, ZERO_BYTES); + vm.stopPrank(); + + // bob has accrued half his principle liquidity + any fees in token0 + assertApproxEqAbs( + IERC20(Currency.unwrap(currency0)).balanceOf(address(bob)) - bobBalance0Before, + uint256(int256(-lpDeltaBob.amount0()) / 2) + + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, liquidityAlice + liquidityBob), + tolerance + ); + // bob has accrued half his principle liquidity + any fees in token0 + assertApproxEqAbs( + IERC20(Currency.unwrap(currency1)).balanceOf(address(bob)) - bobBalance1Before, + uint256(int256(-lpDeltaBob.amount1()) / 2) + + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, liquidityAlice + liquidityBob), + tolerance + ); + } + } + + function test_collect_donate() public {} + function test_collect_donate_sameRange() public {} + // TODO: ERC6909 Support. + function test_collect_6909() public {} + function test_collect_sameRange_6909() public {} +} diff --git a/test/position-managers/Gas.t.sol b/test/position-managers/Gas.t.sol new file mode 100644 index 00000000..df267aec --- /dev/null +++ b/test/position-managers/Gas.t.sol @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.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 {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 {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +import {IPositionManager, Actions} from "../../src/interfaces/IPositionManager.sol"; +import {PositionManager} from "../../src/PositionManager.sol"; +import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; +import {IMulticall} from "../../src/interfaces/IMulticall.sol"; +import {Planner} from "../utils/Planner.sol"; +import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; + +contract GasTest is Test, PosmTestSetup, GasSnapshot { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using PoolIdLibrary for PoolKey; + using Planner for Planner.Plan; + + PoolId poolId; + address alice; + uint256 alicePK; + address bob; + uint256 bobPK; + + // expresses the fee as a wad (i.e. 3000 = 0.003e18 = 0.30%) + uint256 FEE_WAD; + + LiquidityRange range; + + function setUp() public { + (alice, alicePK) = makeAddrAndKey("ALICE"); + (bob, bobPK) = makeAddrAndKey("BOB"); + + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + (key, poolId) = initPool(currency0, 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. + deployAndApprovePosm(manager); + + // Give tokens to Alice and Bob. + seedBalance(alice); + seedBalance(bob); + + // Approve posm for Alice and bob. + approvePosmFor(alice); + approvePosmFor(bob); + + // define a reusable range + range = LiquidityRange({poolKey: key, tickLower: -300, tickUpper: 300}); + } + + function test_gas_mint() public { + Planner.Plan memory planner = + Planner.init().add(Actions.MINT, abi.encode(range, 10_000 ether, address(this), ZERO_BYTES)); + bytes memory calls = planner.finalize(range.poolKey); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_mint"); + } + + function test_gas_mint_differentRanges() public { + // Explicitly mint to a new range on the same pool. + LiquidityRange memory bob_mint = LiquidityRange({poolKey: key, tickLower: 0, tickUpper: 60}); + vm.startPrank(bob); + mint(bob_mint, 10_000 ether, address(bob), ZERO_BYTES); + vm.stopPrank(); + // Mint to a diff range, diff user. + Planner.Plan memory planner = + Planner.init().add(Actions.MINT, abi.encode(range, 10_000 ether, address(alice), ZERO_BYTES)); + bytes memory calls = planner.finalize(range.poolKey); + vm.prank(alice); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_mint_warmedPool_differentRange"); + } + + function test_gas_mint_sameTickLower() public { + // Explicitly mint to range whos tickLower is the same. + LiquidityRange memory bob_mint = LiquidityRange({poolKey: key, tickLower: range.tickLower, tickUpper: -60}); + vm.startPrank(bob); + mint(bob_mint, 10_000 ether, address(bob), ZERO_BYTES); + vm.stopPrank(); + // Mint to a diff range, diff user. + Planner.Plan memory planner = + Planner.init().add(Actions.MINT, abi.encode(range, 10_000 ether, address(alice), ZERO_BYTES)); + bytes memory calls = planner.finalize(range.poolKey); + vm.prank(alice); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_mint_onSameTickLower"); + } + + function test_gas_mint_sameTickUpper() public { + // Explicitly mint to range whos tickUpperis the same. + LiquidityRange memory bob_mint = LiquidityRange({poolKey: key, tickLower: 60, tickUpper: range.tickUpper}); + vm.startPrank(bob); + mint(bob_mint, 10_000 ether, address(bob), ZERO_BYTES); + vm.stopPrank(); + // Mint to a diff range, diff user. + Planner.Plan memory planner = + Planner.init().add(Actions.MINT, abi.encode(range, 10_000 ether, address(alice), ZERO_BYTES)); + bytes memory calls = planner.finalize(range.poolKey); + vm.prank(alice); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_mint_onSameTickUpper"); + } + + function test_gas_increaseLiquidity_erc20() public { + mint(range, 10_000 ether, address(this), ZERO_BYTES); + uint256 tokenId = lpm.nextTokenId() - 1; + + Planner.Plan memory planner = + Planner.init().add(Actions.INCREASE, abi.encode(tokenId, 10_000 ether, ZERO_BYTES)); + + bytes memory calls = planner.finalize(range.poolKey); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_increaseLiquidity_erc20"); + } + + function test_gas_autocompound_exactUnclaimedFees() public { + // Alice and Bob provide liquidity on the range + // Alice uses her exact fees to increase liquidity (compounding) + + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + + // alice provides liquidity + vm.prank(alice); + mint(range, liquidityAlice, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + mint(range, liquidityBob, bob, ZERO_BYTES); + + // donate to create fees + uint256 amountDonate = 0.2e18; + donateRouter.donate(key, amountDonate, amountDonate, ZERO_BYTES); + + // alice uses her exact fees to increase liquidity + uint256 tokensOwedAlice = amountDonate.mulDivDown(liquidityAlice, liquidityAlice + liquidityBob) - 1; + + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + tokensOwedAlice, + tokensOwedAlice + ); + + Planner.Plan memory planner = + Planner.init().add(Actions.INCREASE, abi.encode(tokenIdAlice, liquidityDelta, ZERO_BYTES)); + + bytes memory calls = planner.finalize(range.poolKey); + vm.prank(alice); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_increase_autocompoundExactUnclaimedFees"); + } + + // Autocompounding but the excess fees are taken to the user + function test_gas_autocompound_excessFeesCredit() public { + // Alice and Bob provide liquidity on the range + // Alice uses her fees to increase liquidity. Excess fees are accounted to alice + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + + // alice provides liquidity + vm.prank(alice); + mint(range, liquidityAlice, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + mint(range, liquidityBob, bob, ZERO_BYTES); + + // donate to create fees + uint256 amountDonate = 20e18; + donateRouter.donate(key, amountDonate, amountDonate, ZERO_BYTES); + + // alice will use half of her fees to increase liquidity + uint256 halfTokensOwedAlice = (amountDonate.mulDivDown(liquidityAlice, liquidityAlice + liquidityBob) - 1) / 2; + + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + halfTokensOwedAlice, + halfTokensOwedAlice + ); + + Planner.Plan memory planner = + Planner.init().add(Actions.INCREASE, abi.encode(tokenIdAlice, liquidityDelta, ZERO_BYTES)); + + bytes memory calls = planner.finalize(range.poolKey); + + vm.prank(alice); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_increase_autocompoundExcessFeesCredit"); + } + + function test_gas_decreaseLiquidity() public { + mint(range, 10_000 ether, address(this), ZERO_BYTES); + uint256 tokenId = lpm.nextTokenId() - 1; + + Planner.Plan memory planner = + Planner.init().add(Actions.DECREASE, abi.encode(tokenId, 10_000 ether, ZERO_BYTES)); + + bytes memory calls = planner.finalize(range.poolKey); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_decreaseLiquidity"); + } + + function test_gas_multicall_initialize_mint() public { + key = PoolKey({currency0: currency0, currency1: currency1, fee: 0, tickSpacing: 10, hooks: IHooks(address(0))}); + + // Use multicall to initialize a pool and mint liquidity + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSelector(lpm.initializePool.selector, key, SQRT_PRICE_1_1, ZERO_BYTES); + + range = LiquidityRange({ + poolKey: key, + tickLower: TickMath.minUsableTick(key.tickSpacing), + tickUpper: TickMath.maxUsableTick(key.tickSpacing) + }); + + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.MINT, abi.encode(range, 100e18, address(this), ZERO_BYTES)); + bytes memory actions = planner.finalize(range.poolKey); + + calls[1] = abi.encodeWithSelector(IPositionManager.modifyLiquidities.selector, actions, _deadline); + + IMulticall(lpm).multicall(calls); + snapLastCall("PositionManager_multicall_initialize_mint"); + } + + function test_gas_collect() public { + mint(range, 10_000 ether, address(this), ZERO_BYTES); + uint256 tokenId = lpm.nextTokenId() - 1; + + // donate to create fee revenue + donateRouter.donate(range.poolKey, 0.2e18, 0.2e18, ZERO_BYTES); + + // Collect by calling decrease with 0. + Planner.Plan memory planner = Planner.init().add(Actions.DECREASE, abi.encode(tokenId, 0, ZERO_BYTES, false)); + + bytes memory calls = planner.finalize(range.poolKey); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_collect"); + } + + // same-range gas tests + function test_gas_sameRange_mint() public { + mint(range, 10_000 ether, address(this), ZERO_BYTES); + + Planner.Plan memory planner = + Planner.init().add(Actions.MINT, abi.encode(range, 10_001 ether, address(alice), ZERO_BYTES)); + bytes memory calls = planner.finalize(range.poolKey); + vm.prank(alice); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_mint_sameRange"); + } + + function test_gas_sameRange_decrease() public { + // two positions of the same range, one of them decreases the entirety of the liquidity + vm.startPrank(alice); + mint(range, 10_000 ether, address(this), ZERO_BYTES); + vm.stopPrank(); + + mint(range, 10_000 ether, address(this), ZERO_BYTES); + uint256 tokenId = lpm.nextTokenId() - 1; + + Planner.Plan memory planner = + Planner.init().add(Actions.DECREASE, abi.encode(tokenId, 10_000 ether, ZERO_BYTES, false)); + + bytes memory calls = planner.finalize(range.poolKey); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_decrease_sameRange_allLiquidity"); + } + + function test_gas_sameRange_collect() public { + // two positions of the same range, one of them collects all their fees + vm.startPrank(alice); + mint(range, 10_000 ether, address(this), ZERO_BYTES); + vm.stopPrank(); + + mint(range, 10_000 ether, address(this), ZERO_BYTES); + uint256 tokenId = lpm.nextTokenId() - 1; + + // donate to create fee revenue + donateRouter.donate(range.poolKey, 0.2e18, 0.2e18, ZERO_BYTES); + + Planner.Plan memory planner = Planner.init().add(Actions.DECREASE, abi.encode(tokenId, 0, ZERO_BYTES, false)); + + bytes memory calls = planner.finalize(range.poolKey); + lpm.modifyLiquidities(calls, _deadline); + snapLastCall("PositionManager_collect_sameRange"); + } + + // TODO: ERC6909 Support. + function test_gas_increaseLiquidity_erc6909() public {} + function test_gas_decreaseLiquidity_erc6909() public {} + + function test_gas_burn() public {} + function test_gas_burnEmpty() public {} +} diff --git a/test/position-managers/IncreaseLiquidity.t.sol b/test/position-managers/IncreaseLiquidity.t.sol new file mode 100644 index 00000000..7d489e91 --- /dev/null +++ b/test/position-managers/IncreaseLiquidity.t.sol @@ -0,0 +1,311 @@ +// 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 {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} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {Fuzzers} from "@uniswap/v4-core/src/test/Fuzzers.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +import {PositionManager} from "../../src/PositionManager.sol"; +import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; +import {Actions, IPositionManager} from "../../src/interfaces/IPositionManager.sol"; +import {Planner} from "../utils/Planner.sol"; +import {FeeMath} from "../shared/FeeMath.sol"; +import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; + +contract IncreaseLiquidityTest is Test, PosmTestSetup, Fuzzers { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using PoolIdLibrary for PoolKey; + using Planner for Planner.Plan; + using FeeMath for IPositionManager; + + PoolId poolId; + address alice = makeAddr("ALICE"); + address bob = makeAddr("BOB"); + + // expresses the fee as a wad (i.e. 3000 = 0.003e18 = 0.30%) + uint256 FEE_WAD; + + LiquidityRange range; + + // Error tolerance. + uint256 tolerance = 0.00000000001 ether; + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + (key, poolId) = initPool(currency0, 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. + deployAndApprovePosm(manager); + + // Give tokens to Alice and Bob. + seedBalance(alice); + seedBalance(bob); + + // Approve posm for Alice and bob. + approvePosmFor(alice); + approvePosmFor(bob); + + // define a reusable range + range = LiquidityRange({poolKey: key, tickLower: -300, tickUpper: 300}); + } + + function test_increaseLiquidity_withExactFees() public { + // Alice and Bob provide liquidity on the range + // Alice uses her exact fees to increase liquidity (compounding) + + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + + // alice provides liquidity + vm.prank(alice); + mint(range, liquidityAlice, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + mint(range, liquidityBob, bob, ZERO_BYTES); + + // swap to create fees + uint256 swapAmount = 0.001e18; + swap(key, true, -int256(swapAmount), ZERO_BYTES); + swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back + + // alice uses her exact fees to increase liquidity + // Slight error in this calculation vs. actual fees.. TODO: Fix this. + BalanceDelta feesOwedAlice = IPositionManager(lpm).getFeesOwed(manager, tokenIdAlice); + // Note: You can alternatively calculate Alice's fees owed from the swap amount, fee on the pool, and total liquidity in that range. + // swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityAlice, liquidityAlice + liquidityBob); + + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + uint256(int256(feesOwedAlice.amount0())), + uint256(int256(feesOwedAlice.amount1())) + ); + + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + + // TODO: Can we make this easier to re-invest fees, so that you don't need to know the exact collect amount? + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.INCREASE, abi.encode(tokenIdAlice, liquidityDelta, ZERO_BYTES)); + bytes memory calls = planner.finalize(range.poolKey); + vm.startPrank(alice); + lpm.modifyLiquidities(calls, _deadline); + vm.stopPrank(); + + // alice barely spent any tokens + // TODO: Use clear. + assertApproxEqAbs(balance0BeforeAlice, currency0.balanceOf(alice), tolerance); + assertApproxEqAbs(balance1BeforeAlice, currency1.balanceOf(alice), tolerance); + } + + // uses donate to simulate fee revenue + function test_increaseLiquidity_withExactFees_donate() public { + // Alice and Bob provide liquidity on the range + // Alice uses her exact fees to increase liquidity (compounding) + + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + + // alice provides liquidity + vm.prank(alice); + mint(range, liquidityAlice, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + mint(range, liquidityBob, bob, ZERO_BYTES); + + // donate to create fees + uint256 amountDonate = 0.2e18; + donateRouter.donate(key, 0.2e18, 0.2e18, ZERO_BYTES); + + // subtract 1 cause we'd rather take than pay + uint256 feesAmount = amountDonate.mulDivDown(liquidityAlice, liquidityAlice + liquidityBob) - 1; + + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + feesAmount, + feesAmount + ); + + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + + vm.startPrank(alice); + increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES); + vm.stopPrank(); + + // alice barely spent any tokens + // TODO: Use clear. + assertApproxEqAbs(balance0BeforeAlice, currency0.balanceOf(alice), tolerance); + assertApproxEqAbs(balance1BeforeAlice, currency1.balanceOf(alice), tolerance); + } + + function test_increaseLiquidity_sameRange_withExcessFees() public { + // Alice and Bob provide liquidity on the same range + // Alice uses half her fees to increase liquidity. The other half are collected to her wallet. + // Bob collects all fees. + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + uint256 totalLiquidity = liquidityAlice + liquidityBob; + + // alice provides liquidity + vm.prank(alice); + mint(range, liquidityAlice, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + mint(range, liquidityBob, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // swap to create fees + uint256 swapAmount = 0.001e18; + swap(key, true, -int256(swapAmount), ZERO_BYTES); + swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back + + // alice will use half of her fees to increase liquidity + BalanceDelta aliceFeesOwed = IPositionManager(lpm).getFeesOwed(manager, tokenIdAlice); + + { + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + uint256(int256(aliceFeesOwed.amount0() / 2)), + uint256(int256(aliceFeesOwed.amount1() / 2)) + ); + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + + vm.startPrank(alice); + increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES); + vm.stopPrank(); + + assertApproxEqAbs( + currency0.balanceOf(alice) - balance0BeforeAlice, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityAlice, totalLiquidity) / 2, + tolerance + ); + assertApproxEqAbs( + currency1.balanceOf(alice) - balance1BeforeAlice, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityAlice, totalLiquidity) / 2, + tolerance + ); + } + + { + // bob collects his fees + uint256 balance0BeforeBob = currency0.balanceOf(bob); + uint256 balance1BeforeBob = currency1.balanceOf(bob); + vm.startPrank(bob); + collect(tokenIdBob, ZERO_BYTES); + vm.stopPrank(); + + assertApproxEqAbs( + currency0.balanceOf(bob) - balance0BeforeBob, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, totalLiquidity), + tolerance + ); + assertApproxEqAbs( + currency1.balanceOf(bob) - balance1BeforeBob, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, totalLiquidity), + tolerance + ); + } + } + + function test_increaseLiquidity_withInsufficientFees() public { + // Alice and Bob provide liquidity on the range + // Alice uses her fees to increase liquidity. Additional funds are used by alice to increase liquidity + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + uint256 totalLiquidity = liquidityAlice + liquidityBob; + + // alice provides liquidity + vm.prank(alice); + mint(range, liquidityAlice, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + mint(range, liquidityBob, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // swap to create fees + uint256 swapAmount = 0.001e18; + swap(key, true, -int256(swapAmount), ZERO_BYTES); + swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back + + // alice will use all of her fees + additional capital to increase liquidity + BalanceDelta feesOwed = IPositionManager(lpm).getFeesOwed(manager, tokenIdAlice); + + { + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + uint256(int256(feesOwed.amount0())) * 2, + uint256(int256(feesOwed.amount1())) * 2 + ); + + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + vm.startPrank(alice); + increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES); + vm.stopPrank(); + uint256 balance0AfterAlice = currency0.balanceOf(alice); + uint256 balance1AfterAlice = currency1.balanceOf(alice); + + // Alice owed feesOwed amount in 0 and 1 because she places feesOwed * 2 back into the pool. + assertApproxEqAbs(balance0BeforeAlice - balance0AfterAlice, uint256(int256(feesOwed.amount0())), tolerance); + assertApproxEqAbs(balance1BeforeAlice - balance1AfterAlice, uint256(int256(feesOwed.amount1())), tolerance); + } + + { + // bob collects his fees + uint256 balance0BeforeBob = currency0.balanceOf(bob); + uint256 balance1BeforeBob = currency1.balanceOf(bob); + vm.startPrank(bob); + collect(tokenIdBob, ZERO_BYTES); + vm.stopPrank(); + uint256 balance0AfterBob = currency0.balanceOf(bob); + uint256 balance1AfterBob = currency1.balanceOf(bob); + assertApproxEqAbs( + balance0AfterBob - balance0BeforeBob, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, totalLiquidity), + tolerance + ); + assertApproxEqAbs( + balance1AfterBob - balance1BeforeBob, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, totalLiquidity), + tolerance + ); + } + } +} diff --git a/test/position-managers/PositionManager.multicall.t.sol b/test/position-managers/PositionManager.multicall.t.sol new file mode 100644 index 00000000..0a7aa688 --- /dev/null +++ b/test/position-managers/PositionManager.multicall.t.sol @@ -0,0 +1,70 @@ +// 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 {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +import {IPositionManager, Actions} from "../../src/interfaces/IPositionManager.sol"; +import {PositionManager} from "../../src/PositionManager.sol"; +import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; +import {IMulticall} from "../../src/interfaces/IMulticall.sol"; +import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; +import {Planner} from "../utils/Planner.sol"; +import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; + +contract PositionManagerMulticallTest is Test, PosmTestSetup, LiquidityFuzzers { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using Planner for Planner.Plan; + + PoolId poolId; + address alice = makeAddr("ALICE"); + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + + // Requires currency0 and currency1 to be set in base Deployers contract. + deployAndApprovePosm(manager); + } + + function test_multicall_initializePool_mint() public { + key = PoolKey({currency0: currency0, currency1: currency1, fee: 0, tickSpacing: 10, hooks: IHooks(address(0))}); + + // Use multicall to initialize a pool and mint liquidity + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSelector(lpm.initializePool.selector, key, SQRT_PRICE_1_1, ZERO_BYTES); + + LiquidityRange memory range = LiquidityRange({ + poolKey: key, + tickLower: TickMath.minUsableTick(key.tickSpacing), + tickUpper: TickMath.maxUsableTick(key.tickSpacing) + }); + + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.MINT, abi.encode(range, 100e18, address(this), ZERO_BYTES)); + bytes memory actions = planner.finalize(range.poolKey); + + calls[1] = abi.encodeWithSelector(IPositionManager.modifyLiquidities.selector, actions, _deadline); + + IMulticall(address(lpm)).multicall(calls); + + // test swap, doesn't revert, showing the pool was initialized + int256 amountSpecified = -1e18; + BalanceDelta result = swap(key, true, amountSpecified, ZERO_BYTES); + assertEq(result.amount0(), amountSpecified); + assertGt(result.amount1(), 0); + } +} diff --git a/test/position-managers/PositionManager.t.sol b/test/position-managers/PositionManager.t.sol new file mode 100644 index 00000000..30679d17 --- /dev/null +++ b/test/position-managers/PositionManager.t.sol @@ -0,0 +1,258 @@ +// 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 {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} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; + +import {IPositionManager, Actions} from "../../src/interfaces/IPositionManager.sol"; +import {PositionManager} from "../../src/PositionManager.sol"; +import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; +import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; +import {Planner} from "../utils/Planner.sol"; +import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; + +contract PositionManagerTest is Test, PosmTestSetup, LiquidityFuzzers { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using Planner for Planner.Plan; + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + + PoolId poolId; + address alice = makeAddr("ALICE"); + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + + // Requires currency0 and currency1 to be set in base Deployers contract. + deployAndApprovePosm(manager); + } + + function test_modifyLiquidities_reverts_mismatchedLengths() public { + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.MINT, abi.encode("test")); + planner = planner.add(Actions.BURN, abi.encode("test")); + + bytes[] memory badParams = new bytes[](1); + + vm.expectRevert(IPositionManager.MismatchedLengths.selector); + lpm.modifyLiquidities(abi.encode(planner.actions, badParams), block.timestamp + 1); + } + + function test_fuzz_mint_withLiquidityDelta(IPoolManager.ModifyLiquidityParams memory params, uint160 sqrtPriceX96) + public + { + bound(sqrtPriceX96, MIN_PRICE_LIMIT, MAX_PRICE_LIMIT); + params = createFuzzyLiquidityParams(key, params, sqrtPriceX96); + // liquidity is a uint + 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(); + BalanceDelta delta = mint(range, liquidityToAdd, address(this), ZERO_BYTES); + + assertEq(tokenId, 1); + assertEq(lpm.nextTokenId(), 2); + assertEq(lpm.ownerOf(tokenId), address(this)); + + 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"); + } + + function test_mint_exactTokenRatios() public { + int24 tickLower = -int24(key.tickSpacing); + int24 tickUpper = int24(key.tickSpacing); + uint256 amount0Desired = 100e18; + uint256 amount1Desired = 100e18; + uint256 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(tickLower), + TickMath.getSqrtPriceAtTick(tickUpper), + amount0Desired, + amount1Desired + ); + + LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); + + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + + uint256 tokenId = lpm.nextTokenId(); + BalanceDelta delta = mint(range, liquidityToAdd, address(this), ZERO_BYTES); + + uint256 balance0After = currency0.balanceOfSelf(); + uint256 balance1After = currency1.balanceOfSelf(); + + assertEq(tokenId, 1); + assertEq(lpm.ownerOf(1), address(this)); + + assertEq(uint256(int256(-delta.amount0())), amount0Desired); + assertEq(uint256(int256(-delta.amount1())), amount1Desired); + assertEq(balance0Before - balance0After, uint256(int256(-delta.amount0()))); + assertEq(balance1Before - balance1After, uint256(int256(-delta.amount1()))); + } + + function test_fuzz_mint_recipient(IPoolManager.ModifyLiquidityParams memory seedParams) public { + IPoolManager.ModifyLiquidityParams memory params = createFuzzyLiquidityParams(key, seedParams, SQRT_PRICE_1_1); + 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(); + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + BalanceDelta delta = mint(range, liquidityToAdd, alice, ZERO_BYTES); + + assertEq(tokenId, 1); + assertEq(lpm.ownerOf(tokenId), alice); + + // alice was not the payer + assertEq(balance0Before - currency0.balanceOfSelf(), uint256(int256(-delta.amount0()))); + assertEq(balance1Before - currency1.balanceOfSelf(), uint256(int256(-delta.amount1()))); + assertEq(currency0.balanceOf(alice), balance0BeforeAlice); + assertEq(currency1.balanceOf(alice), balance1BeforeAlice); + } + + function test_fuzz_burn(IPoolManager.ModifyLiquidityParams memory params) public { + uint256 balance0Start = currency0.balanceOfSelf(); + uint256 balance1Start = currency1.balanceOfSelf(); + + // create liquidity we can burn + uint256 tokenId; + (tokenId, params) = addFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + assertEq(tokenId, 1); + assertEq(lpm.ownerOf(1), address(this)); + + 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); + + assertEq(currency0.balanceOfSelf(), balance0BeforeBurn + uint256(int256(deltaDecrease.amount0()))); + assertEq(currency1.balanceOfSelf(), balance1BeforeBurn + uint256(uint128(deltaDecrease.amount1()))); + + // 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 + // Potentially because we round down in core. I believe this is known in V3. But let's check! + assertApproxEqAbs(currency0.balanceOfSelf(), balance0Start, 1 wei); + assertApproxEqAbs(currency1.balanceOfSelf(), balance1Start, 1 wei); + } + + function test_fuzz_decreaseLiquidity( + IPoolManager.ModifyLiquidityParams memory params, + uint256 decreaseLiquidityDelta + ) public { + uint256 tokenId; + (tokenId, params) = addFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); + decreaseLiquidityDelta = uint256(bound(int256(decreaseLiquidityDelta), 0, params.liquidityDelta)); + + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + 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); + } + + function test_fuzz_decreaseLiquidity_assertCollectedBalance( + IPoolManager.ModifyLiquidityParams memory params, + uint256 decreaseLiquidityDelta + ) public { + uint256 tokenId; + (tokenId, params) = addFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // require two-sided liquidity + vm.assume(0 < decreaseLiquidityDelta); + vm.assume(decreaseLiquidityDelta < uint256(type(int256).max)); + vm.assume(int256(decreaseLiquidityDelta) <= params.liquidityDelta); + + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + // swap to create fees + uint256 swapAmount = 0.01e18; + swap(key, false, int256(swapAmount), ZERO_BYTES); + + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + 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); + + // The change in balance equals the delta returned. + assertEq(currency0.balanceOfSelf() - balance0Before, uint256(int256(delta.amount0()))); + assertEq(currency1.balanceOfSelf() - balance1Before, uint256(int256(delta.amount1()))); + } + + function test_initialize() public { + // initialize a new pool and add liquidity + key = PoolKey({currency0: currency0, currency1: currency1, fee: 0, tickSpacing: 10, hooks: IHooks(address(0))}); + lpm.initializePool(key, SQRT_PRICE_1_1, ZERO_BYTES); + + (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee) = manager.getSlot0(key.toId()); + assertEq(sqrtPriceX96, SQRT_PRICE_1_1); + assertEq(tick, 0); + assertEq(protocolFee, 0); + assertEq(lpFee, key.fee); + } + + function test_initialize_fuzz() public {} + function test_mintTransferBurn() public {} + function test_mintTransferCollect() public {} + function test_mintTransferIncrease() public {} + function test_mintTransferDecrease() public {} + function test_mint_slippageRevert() public {} +} diff --git a/test/shared/FeeMath.sol b/test/shared/FeeMath.sol new file mode 100644 index 00000000..e549d588 --- /dev/null +++ b/test/shared/FeeMath.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; +import {FixedPoint128} from "@uniswap/v4-core/src/libraries/FixedPoint128.sol"; +import {Position} from "@uniswap/v4-core/src/libraries/Position.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; + +import {IPositionManager} from "../../src/interfaces/IPositionManager.sol"; +import {PositionManager} from "../../src/PositionManager.sol"; +import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; + +library FeeMath { + using SafeCast for uint256; + using StateLibrary for IPoolManager; + using PoolIdLibrary for PoolKey; + using PoolIdLibrary for PoolKey; + + /// @notice Calculates the fees accrued to a position. Used for testing purposes. + function getFeesOwed(IPositionManager posm, IPoolManager manager, uint256 tokenId) + internal + view + returns (BalanceDelta feesOwed) + { + (PoolKey memory poolKey, int24 tickLower, int24 tickUpper) = posm.tokenRange(tokenId); + + // getPosition(poolId, owner, tL, tU, salt) + // owner is the position manager + // salt is the tokenId + Position.Info memory position = + manager.getPosition(poolKey.toId(), address(posm), tickLower, tickUpper, bytes32(tokenId)); + + (uint256 feeGrowthInside0X218, uint256 feeGrowthInside1X128) = + manager.getFeeGrowthInside(poolKey.toId(), tickLower, tickUpper); + + feesOwed = getFeesOwed( + feeGrowthInside0X218, + feeGrowthInside1X128, + position.feeGrowthInside0LastX128, + position.feeGrowthInside1LastX128, + position.liquidity + ); + } + + function getFeesOwed( + uint256 feeGrowthInside0X128, + uint256 feeGrowthInside1X128, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint256 liquidity + ) internal pure returns (BalanceDelta feesOwed) { + uint128 token0Owed = getFeeOwed(feeGrowthInside0X128, feeGrowthInside0LastX128, liquidity); + uint128 token1Owed = getFeeOwed(feeGrowthInside1X128, feeGrowthInside1LastX128, liquidity); + feesOwed = toBalanceDelta(uint256(token0Owed).toInt128(), uint256(token1Owed).toInt128()); + } + + function getFeeOwed(uint256 feeGrowthInsideX128, uint256 feeGrowthInsideLastX128, uint256 liquidity) + internal + pure + returns (uint128 tokenOwed) + { + tokenOwed = + (FullMath.mulDiv(feeGrowthInsideX128 - feeGrowthInsideLastX128, liquidity, FixedPoint128.Q128)).toUint128(); + } +} diff --git a/test/shared/LiquidityOperations.sol b/test/shared/LiquidityOperations.sol new file mode 100644 index 00000000..caec186e --- /dev/null +++ b/test/shared/LiquidityOperations.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +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 {PositionManager, Actions} from "../../src/PositionManager.sol"; +import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; +import {Planner} from "../utils/Planner.sol"; + +abstract contract LiquidityOperations is CommonBase { + using Planner for Planner.Plan; + + PositionManager lpm; + + uint256 _deadline = block.timestamp + 1; + + function mint(LiquidityRange memory _range, uint256 liquidity, address recipient, bytes memory hookData) + 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 result = lpm.modifyLiquidities(calls, _deadline); + return abi.decode(result[0], (BalanceDelta)); + } + + function increaseLiquidity(uint256 tokenId, uint256 liquidityToAdd, bytes memory hookData) + internal + returns (BalanceDelta) + { + bytes memory calls = getIncreaseEncoded(tokenId, liquidityToAdd, hookData); + bytes[] memory result = lpm.modifyLiquidities(calls, _deadline); + return abi.decode(result[0], (BalanceDelta)); + } + + // do not make external call before unlockAndExecute, allows us to test reverts + function decreaseLiquidity(uint256 tokenId, uint256 liquidityToRemove, bytes memory hookData) + internal + returns (BalanceDelta) + { + bytes memory calls = getDecreaseEncoded(tokenId, liquidityToRemove, hookData); + bytes[] memory result = lpm.modifyLiquidities(calls, _deadline); + return abi.decode(result[0], (BalanceDelta)); + } + + function collect(uint256 tokenId, bytes memory hookData) internal returns (BalanceDelta) { + bytes memory calls = getCollectEncoded(tokenId, hookData); + bytes[] memory result = lpm.modifyLiquidities(calls, _deadline); + return abi.decode(result[0], (BalanceDelta)); + } + + function burn(uint256 tokenId) internal { + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.BURN, abi.encode(tokenId)); + // No close needed on burn. + bytes memory actions = planner.encode(); + lpm.modifyLiquidities(actions, _deadline); + } + + // Helper functions for getting encoded calldata for .modifyLiquidities + function getIncreaseEncoded(uint256 tokenId, uint256 liquidityToAdd, bytes memory hookData) + internal + view + returns (bytes memory) + { + (PoolKey memory key,,) = lpm.tokenRange(tokenId); + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.INCREASE, abi.encode(tokenId, liquidityToAdd, hookData)); + return planner.finalize(key); + } + + function getDecreaseEncoded(uint256 tokenId, uint256 liquidityToRemove, bytes memory hookData) + internal + view + returns (bytes memory) + { + (PoolKey memory key,,) = lpm.tokenRange(tokenId); + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.DECREASE, abi.encode(tokenId, liquidityToRemove, hookData)); + return planner.finalize(key); + } + + function getCollectEncoded(uint256 tokenId, bytes memory hookData) internal view returns (bytes memory) { + (PoolKey memory poolKey,,) = lpm.tokenRange(tokenId); + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.DECREASE, abi.encode(tokenId, 0, hookData)); + return planner.finalize(poolKey); + } +} diff --git a/test/shared/PosmTestSetup.sol b/test/shared/PosmTestSetup.sol new file mode 100644 index 00000000..4185ea22 --- /dev/null +++ b/test/shared/PosmTestSetup.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +import {PositionManager} from "../../src/PositionManager.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {LiquidityOperations} from "./LiquidityOperations.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, LiquidityOperations { + uint256 constant STARTING_USER_BALANCE = 10_000_000 ether; + + function deployAndApprovePosm(IPoolManager poolManager) public { + lpm = new PositionManager(poolManager); + approvePosm(); + } + + function seedBalance(address to) public { + IERC20(Currency.unwrap(currency0)).transfer(to, STARTING_USER_BALANCE); + IERC20(Currency.unwrap(currency1)).transfer(to, STARTING_USER_BALANCE); + } + + function approvePosm() public { + IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + } + + function approvePosmFor(address addr) public { + vm.startPrank(addr); + IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + vm.stopPrank(); + } +} diff --git a/test/shared/fuzz/LiquidityFuzzers.sol b/test/shared/fuzz/LiquidityFuzzers.sol new file mode 100644 index 00000000..c15c8cd9 --- /dev/null +++ b/test/shared/fuzz/LiquidityFuzzers.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {Fuzzers} from "@uniswap/v4-core/src/test/Fuzzers.sol"; + +import {IPositionManager, Actions} from "../../../src/interfaces/IPositionManager.sol"; +import {LiquidityRange} from "../../../src/types/LiquidityRange.sol"; +import {Planner} from "../../utils/Planner.sol"; + +contract LiquidityFuzzers is Fuzzers { + using Planner for Planner.Plan; + + function addFuzzyLiquidity( + IPositionManager lpm, + address recipient, + PoolKey memory key, + IPoolManager.ModifyLiquidityParams memory params, + uint160 sqrtPriceX96, + bytes memory hookData + ) internal returns (uint256, IPoolManager.ModifyLiquidityParams memory) { + params = Fuzzers.createFuzzyLiquidityParams(key, params, sqrtPriceX96); + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + Planner.Plan memory planner = + Planner.init().add(Actions.MINT, abi.encode(range, uint256(params.liquidityDelta), recipient, hookData)); + + bytes memory calls = planner.finalize(range.poolKey); + lpm.modifyLiquidities(calls, block.timestamp + 1); + + uint256 tokenId = lpm.nextTokenId() - 1; + return (tokenId, params); + } +} diff --git a/test/utils/Planner.sol b/test/utils/Planner.sol new file mode 100644 index 00000000..7c372855 --- /dev/null +++ b/test/utils/Planner.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; + +import {IPositionManager, Actions} from "../../src/interfaces/IPositionManager.sol"; +import {LiquidityRange} from "../../src/types/LiquidityRange.sol"; + +library Planner { + using Planner for Plan; + + struct Plan { + Actions[] actions; + bytes[] params; + } + + function init() internal pure returns (Plan memory plan) { + return Plan({actions: new Actions[](0), params: new bytes[](0)}); + } + + function add(Plan memory plan, Actions action, bytes memory param) internal pure returns (Plan memory) { + Actions[] memory actions = new Actions[](plan.actions.length + 1); + bytes[] memory params = new bytes[](plan.params.length + 1); + + for (uint256 i; i < actions.length - 1; i++) { + // Copy from plan. + actions[i] = plan.actions[i]; + params[i] = plan.params[i]; + } + + actions[actions.length - 1] = action; + params[params.length - 1] = param; + + return Plan({actions: actions, params: params}); + } + + function finalize(Plan memory plan, PoolKey memory poolKey) internal pure returns (bytes memory) { + plan = plan.add(Actions.CLOSE_CURRENCY, abi.encode(poolKey.currency0)); + plan = plan.add(Actions.CLOSE_CURRENCY, abi.encode(poolKey.currency1)); + return plan.encode(); + } + + function encode(Plan memory plan) internal pure returns (bytes memory) { + return abi.encode(plan.actions, plan.params); + } +}