diff --git a/.forge-snapshots/PositionManager_burn_empty.snap b/.forge-snapshots/PositionManager_burn_empty.snap index 4c184642..a3f146ac 100644 --- a/.forge-snapshots/PositionManager_burn_empty.snap +++ b/.forge-snapshots/PositionManager_burn_empty.snap @@ -1 +1 @@ -47059 \ No newline at end of file +47186 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_burn_empty_native.snap b/.forge-snapshots/PositionManager_burn_empty_native.snap index 9230f513..a04aaf5c 100644 --- a/.forge-snapshots/PositionManager_burn_empty_native.snap +++ b/.forge-snapshots/PositionManager_burn_empty_native.snap @@ -1 +1 @@ -46876 \ No newline at end of file +47004 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_burn_nonEmpty.snap b/.forge-snapshots/PositionManager_burn_nonEmpty.snap index c15f4f2d..49379c42 100644 --- a/.forge-snapshots/PositionManager_burn_nonEmpty.snap +++ b/.forge-snapshots/PositionManager_burn_nonEmpty.snap @@ -1 +1 @@ -129852 \ No newline at end of file +130136 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_burn_nonEmpty_native.snap b/.forge-snapshots/PositionManager_burn_nonEmpty_native.snap index d2ae1c7d..5238cf5f 100644 --- a/.forge-snapshots/PositionManager_burn_nonEmpty_native.snap +++ b/.forge-snapshots/PositionManager_burn_nonEmpty_native.snap @@ -1 +1 @@ -122773 \ No newline at end of file +123058 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect.snap b/.forge-snapshots/PositionManager_collect.snap index 0cf00f2a..1d6bec04 100644 --- a/.forge-snapshots/PositionManager_collect.snap +++ b/.forge-snapshots/PositionManager_collect.snap @@ -1 +1 @@ -149984 \ No newline at end of file +150257 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect_native.snap b/.forge-snapshots/PositionManager_collect_native.snap index 1b627db8..089f3305 100644 --- a/.forge-snapshots/PositionManager_collect_native.snap +++ b/.forge-snapshots/PositionManager_collect_native.snap @@ -1 +1 @@ -141136 \ No newline at end of file +141409 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_collect_sameRange.snap b/.forge-snapshots/PositionManager_collect_sameRange.snap index 0cf00f2a..1d6bec04 100644 --- a/.forge-snapshots/PositionManager_collect_sameRange.snap +++ b/.forge-snapshots/PositionManager_collect_sameRange.snap @@ -1 +1 @@ -149984 \ No newline at end of file +150257 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decreaseLiquidity.snap b/.forge-snapshots/PositionManager_decreaseLiquidity.snap index c30fc9de..5bc59fda 100644 --- a/.forge-snapshots/PositionManager_decreaseLiquidity.snap +++ b/.forge-snapshots/PositionManager_decreaseLiquidity.snap @@ -1 +1 @@ -115527 \ No newline at end of file +115800 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap b/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap index 3c0e3daf..f183904d 100644 --- a/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap +++ b/.forge-snapshots/PositionManager_decreaseLiquidity_native.snap @@ -1 +1 @@ -108384 \ No newline at end of file +108602 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decrease_burnEmpty.snap b/.forge-snapshots/PositionManager_decrease_burnEmpty.snap index 58fe1844..f2edca7c 100644 --- a/.forge-snapshots/PositionManager_decrease_burnEmpty.snap +++ b/.forge-snapshots/PositionManager_decrease_burnEmpty.snap @@ -1 +1 @@ -133885 \ No newline at end of file +134196 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decrease_burnEmpty_native.snap b/.forge-snapshots/PositionManager_decrease_burnEmpty_native.snap index 23aac82f..19fbfe9c 100644 --- a/.forge-snapshots/PositionManager_decrease_burnEmpty_native.snap +++ b/.forge-snapshots/PositionManager_decrease_burnEmpty_native.snap @@ -1 +1 @@ -126624 \ No newline at end of file +126935 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap b/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap index 48ed6687..47317e7b 100644 --- a/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap +++ b/.forge-snapshots/PositionManager_decrease_sameRange_allLiquidity.snap @@ -1 +1 @@ -128243 \ No newline at end of file +128516 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withClose.snap b/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withClose.snap index 26723396..f7f7fed6 100644 --- a/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withClose.snap +++ b/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withClose.snap @@ -1 +1 @@ -152100 \ No newline at end of file +152363 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withSettlePair.snap b/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withSettlePair.snap index 6ce3534b..473fcfde 100644 --- a/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withSettlePair.snap +++ b/.forge-snapshots/PositionManager_increaseLiquidity_erc20_withSettlePair.snap @@ -1 +1 @@ -151341 \ No newline at end of file +151604 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increaseLiquidity_native.snap b/.forge-snapshots/PositionManager_increaseLiquidity_native.snap index 35295eaf..7d3f9d6c 100644 --- a/.forge-snapshots/PositionManager_increaseLiquidity_native.snap +++ b/.forge-snapshots/PositionManager_increaseLiquidity_native.snap @@ -1 +1 @@ -133900 \ No newline at end of file +134163 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap b/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap index 04c138b3..2a9f67c8 100644 --- a/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap +++ b/.forge-snapshots/PositionManager_increase_autocompoundExactUnclaimedFees.snap @@ -1 +1 @@ -130065 \ No newline at end of file +130328 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap b/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap index e55d6257..d1db687b 100644 --- a/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap +++ b/.forge-snapshots/PositionManager_increase_autocompoundExcessFeesCredit.snap @@ -1 +1 @@ -170759 \ No newline at end of file +171022 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_increase_autocompound_clearExcess.snap b/.forge-snapshots/PositionManager_increase_autocompound_clearExcess.snap index 375f518b..6f428511 100644 --- a/.forge-snapshots/PositionManager_increase_autocompound_clearExcess.snap +++ b/.forge-snapshots/PositionManager_increase_autocompound_clearExcess.snap @@ -1 +1 @@ -140581 \ No newline at end of file +140866 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_native.snap b/.forge-snapshots/PositionManager_mint_native.snap index 7d264b1e..99535ce2 100644 --- a/.forge-snapshots/PositionManager_mint_native.snap +++ b/.forge-snapshots/PositionManager_mint_native.snap @@ -1 +1 @@ -336663 \ No newline at end of file +336841 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_nativeWithSweep.snap b/.forge-snapshots/PositionManager_mint_nativeWithSweep.snap deleted file mode 100644 index 4ad1137a..00000000 --- a/.forge-snapshots/PositionManager_mint_nativeWithSweep.snap +++ /dev/null @@ -1 +0,0 @@ -345146 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_nativeWithSweep_withClose.snap b/.forge-snapshots/PositionManager_mint_nativeWithSweep_withClose.snap index 6398f05a..3604c314 100644 --- a/.forge-snapshots/PositionManager_mint_nativeWithSweep_withClose.snap +++ b/.forge-snapshots/PositionManager_mint_nativeWithSweep_withClose.snap @@ -1 +1 @@ -345169 \ No newline at end of file +345347 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_nativeWithSweep_withSettlePair.snap b/.forge-snapshots/PositionManager_mint_nativeWithSweep_withSettlePair.snap index 141f9455..50673d18 100644 --- a/.forge-snapshots/PositionManager_mint_nativeWithSweep_withSettlePair.snap +++ b/.forge-snapshots/PositionManager_mint_nativeWithSweep_withSettlePair.snap @@ -1 +1 @@ -344710 \ No newline at end of file +344888 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_onSameTickLower.snap b/.forge-snapshots/PositionManager_mint_onSameTickLower.snap index 2bce4f8d..00e13762 100644 --- a/.forge-snapshots/PositionManager_mint_onSameTickLower.snap +++ b/.forge-snapshots/PositionManager_mint_onSameTickLower.snap @@ -1 +1 @@ -314645 \ No newline at end of file +314823 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap b/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap index 2f87608a..bf2a5e88 100644 --- a/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap +++ b/.forge-snapshots/PositionManager_mint_onSameTickUpper.snap @@ -1 +1 @@ -315287 \ No newline at end of file +315465 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_sameRange.snap b/.forge-snapshots/PositionManager_mint_sameRange.snap index 1097f3c7..de4b5cb3 100644 --- a/.forge-snapshots/PositionManager_mint_sameRange.snap +++ b/.forge-snapshots/PositionManager_mint_sameRange.snap @@ -1 +1 @@ -240869 \ No newline at end of file +241047 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_settleWithBalance_sweep.snap b/.forge-snapshots/PositionManager_mint_settleWithBalance_sweep.snap index 977b82bc..34f57131 100644 --- a/.forge-snapshots/PositionManager_mint_settleWithBalance_sweep.snap +++ b/.forge-snapshots/PositionManager_mint_settleWithBalance_sweep.snap @@ -1 +1 @@ -370969 \ No newline at end of file +371147 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap b/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap index 59c07930..0ed51014 100644 --- a/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap +++ b/.forge-snapshots/PositionManager_mint_warmedPool_differentRange.snap @@ -1 +1 @@ -320663 \ No newline at end of file +320841 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_withClose.snap b/.forge-snapshots/PositionManager_mint_withClose.snap index 87615faa..f9a92b51 100644 --- a/.forge-snapshots/PositionManager_mint_withClose.snap +++ b/.forge-snapshots/PositionManager_mint_withClose.snap @@ -1 +1 @@ -371963 \ No newline at end of file +372141 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_mint_withSettlePair.snap b/.forge-snapshots/PositionManager_mint_withSettlePair.snap index 2a78d147..7bbeb8cb 100644 --- a/.forge-snapshots/PositionManager_mint_withSettlePair.snap +++ b/.forge-snapshots/PositionManager_mint_withSettlePair.snap @@ -1 +1 @@ -371342 \ No newline at end of file +371520 \ No newline at end of file diff --git a/.forge-snapshots/PositionManager_multicall_initialize_mint.snap b/.forge-snapshots/PositionManager_multicall_initialize_mint.snap index 2a4a2f83..15fd1f72 100644 --- a/.forge-snapshots/PositionManager_multicall_initialize_mint.snap +++ b/.forge-snapshots/PositionManager_multicall_initialize_mint.snap @@ -1 +1 @@ -416316 \ No newline at end of file +416538 \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 54e2ac0c..002bc8be 100644 --- a/foundry.toml +++ b/foundry.toml @@ -11,4 +11,7 @@ fuzz_runs = 10_000 [profile.ci] fuzz_runs = 100_000 +[profile.gas] +gas_limit=30_000_000 + # See more config options https://github.com/foundry-rs/foundry/tree/master/config diff --git a/src/PositionManager.sol b/src/PositionManager.sol index d1418897..ef0041c9 100644 --- a/src/PositionManager.sol +++ b/src/PositionManager.sol @@ -24,7 +24,9 @@ import {DeltaResolver} from "./base/DeltaResolver.sol"; import {PositionConfig, PositionConfigLibrary} from "./libraries/PositionConfig.sol"; import {BaseActionsRouter} from "./base/BaseActionsRouter.sol"; import {Actions} from "./libraries/Actions.sol"; +import {Notifier} from "./base/Notifier.sol"; import {CalldataDecoder} from "./libraries/CalldataDecoder.sol"; +import {INotifier} from "./interfaces/INotifier.sol"; import {Permit2Forwarder} from "./base/Permit2Forwarder.sol"; import {SlippageCheckLibrary} from "./libraries/SlippageCheck.sol"; @@ -36,12 +38,13 @@ contract PositionManager is DeltaResolver, ReentrancyLock, BaseActionsRouter, + Notifier, Permit2Forwarder { using SafeTransferLib for *; using CurrencyLibrary for Currency; using PoolIdLibrary for PoolKey; - using PositionConfigLibrary for PositionConfig; + using PositionConfigLibrary for *; using StateLibrary for IPoolManager; using TransientStateLibrary for IPoolManager; using SafeCast for uint256; @@ -51,8 +54,7 @@ contract PositionManager is /// @dev The ID of the next token that will be minted. Skips 0 uint256 public nextTokenId = 1; - /// @inheritdoc IPositionManager - mapping(uint256 tokenId => bytes32 configId) public positionConfigs; + mapping(uint256 tokenId => bytes32 config) private positionConfigs; constructor(IPoolManager _poolManager, IAllowanceTransfer _permit2) BaseActionsRouter(_poolManager) @@ -60,11 +62,31 @@ contract PositionManager is ERC721Permit("Uniswap V4 Positions NFT", "UNI-V4-POSM", "1") {} + /// @notice Reverts if the deadline has passed + /// @param deadline The timestamp at which the call is no longer valid, passed in by the caller modifier checkDeadline(uint256 deadline) { if (block.timestamp > deadline) revert DeadlinePassed(); _; } + /// @notice Reverts if the caller is not the owner or approved for the ERC721 token + /// @param caller The address of the caller + /// @param tokenId the unique identifier of the ERC721 token + /// @dev either msg.sender or _msgSender() is passed in as the caller + /// _msgSender() should ONLY be used if this is being called from within the unlockCallback + modifier onlyIfApproved(address caller, uint256 tokenId) { + if (!_isApprovedOrOwner(caller, tokenId)) revert NotApproved(caller); + _; + } + + /// @notice Reverts if the hash of the config does not equal the saved hash + /// @param tokenId the unique identifier of the ERC721 token + /// @param config the PositionConfig to check against + modifier onlyValidConfig(uint256 tokenId, PositionConfig calldata config) { + if (positionConfigs.getConfigId(tokenId) != config.toId()) revert IncorrectPositionConfigForTokenId(tokenId); + _; + } + /// @param unlockData is an encoding of actions, params, and currencies /// @param deadline is the timestamp at which the unlockData will no longer be valid function modifyLiquidities(bytes calldata unlockData, uint256 deadline) @@ -76,6 +98,29 @@ contract PositionManager is _executeActions(unlockData); } + /// @inheritdoc INotifier + function subscribe(uint256 tokenId, PositionConfig calldata config, address subscriber) + external + payable + onlyIfApproved(msg.sender, tokenId) + onlyValidConfig(tokenId, config) + { + // call to _subscribe will revert if the user already has a sub + positionConfigs.setSubscribe(tokenId); + _subscribe(tokenId, config, subscriber); + } + + /// @inheritdoc INotifier + function unsubscribe(uint256 tokenId, PositionConfig calldata config) + external + payable + onlyIfApproved(msg.sender, tokenId) + onlyValidConfig(tokenId, config) + { + positionConfigs.setUnsubscribe(tokenId); + _unsubscribe(tokenId, config); + } + function _handleAction(uint256 action, bytes calldata params) internal virtual override { if (action == Actions.INCREASE_LIQUIDITY) { ( @@ -149,8 +194,7 @@ contract PositionManager is uint128 amount0Max, uint128 amount1Max, bytes calldata hookData - ) internal { - if (positionConfigs[tokenId] != config.toId()) revert IncorrectPositionConfigForTokenId(tokenId); + ) internal onlyValidConfig(tokenId, config) { // Note: The tokenId is used as the salt for this position, so every minted position has unique storage in the pool manager. BalanceDelta liquidityDelta = _modifyLiquidity(config, liquidity.toInt256(), bytes32(tokenId), hookData); liquidityDelta.validateMaxInNegative(amount0Max, amount1Max); @@ -164,10 +208,7 @@ contract PositionManager is uint128 amount0Min, uint128 amount1Min, bytes calldata hookData - ) internal { - if (!_isApprovedOrOwner(_msgSender(), tokenId)) revert NotApproved(_msgSender()); - if (positionConfigs[tokenId] != config.toId()) revert IncorrectPositionConfigForTokenId(tokenId); - + ) internal onlyIfApproved(_msgSender(), tokenId) onlyValidConfig(tokenId, config) { // Note: the tokenId is used as the salt. BalanceDelta liquidityDelta = _modifyLiquidity(config, -(liquidity.toInt256()), bytes32(tokenId), hookData); liquidityDelta.validateMinOut(amount0Min, amount1Min); @@ -192,7 +233,7 @@ contract PositionManager is // _beforeModify is not called here because the tokenId is newly minted BalanceDelta liquidityDelta = _modifyLiquidity(config, liquidity.toInt256(), bytes32(tokenId), hookData); liquidityDelta.validateMaxIn(amount0Max, amount1Max); - positionConfigs[tokenId] = config.toId(); + positionConfigs.setConfigId(tokenId, config); } function _close(Currency currency) internal { @@ -231,9 +272,7 @@ contract PositionManager is uint128 amount0Min, uint128 amount1Min, bytes calldata hookData - ) internal { - if (!_isApprovedOrOwner(_msgSender(), tokenId)) revert NotApproved(_msgSender()); - if (positionConfigs[tokenId] != config.toId()) revert IncorrectPositionConfigForTokenId(tokenId); + ) internal onlyIfApproved(_msgSender(), tokenId) onlyValidConfig(tokenId, config) { uint256 liquidity = uint256(_getPositionLiquidity(config, tokenId)); BalanceDelta liquidityDelta; @@ -264,6 +303,10 @@ contract PositionManager is }), hookData ); + + if (positionConfigs.hasSubscriber(uint256(salt))) { + _notifyModifyLiquidity(uint256(salt), config, liquidityChange); + } } function _getPositionLiquidity(PositionConfig calldata config, uint256 tokenId) @@ -291,4 +334,20 @@ contract PositionManager is permit2.transferFrom(payer, address(poolManager), uint160(amount), Currency.unwrap(currency)); } } + + /// @dev overrides solmate transferFrom in case a notification to subscribers is needed + function transferFrom(address from, address to, uint256 id) public override { + super.transferFrom(from, to, id); + if (positionConfigs.hasSubscriber(id)) _notifyTransfer(id, from, to); + } + + /// @inheritdoc IPositionManager + function getPositionConfigId(uint256 tokenId) external view returns (bytes32) { + return positionConfigs.getConfigId(tokenId); + } + + /// @inheritdoc INotifier + function hasSubscriber(uint256 tokenId) external view returns (bool) { + return positionConfigs.hasSubscriber(tokenId); + } } diff --git a/src/base/Notifier.sol b/src/base/Notifier.sol new file mode 100644 index 00000000..97510888 --- /dev/null +++ b/src/base/Notifier.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +import {ISubscriber} from "../interfaces/ISubscriber.sol"; +import {PositionConfig} from "../libraries/PositionConfig.sol"; +import {GasLimitCalculator} from "../libraries/GasLimitCalculator.sol"; + +import "../interfaces/INotifier.sol"; + +/// @notice Notifier is used to opt in to sending updates to external contracts about position modifications or transfers +abstract contract Notifier is INotifier { + using GasLimitCalculator for uint256; + + error AlreadySubscribed(address subscriber); + + event Subscribed(uint256 tokenId, address subscriber); + event Unsubscribed(uint256 tokenId, address subscriber); + + ISubscriber private constant NO_SUBSCRIBER = ISubscriber(address(0)); + + // a percentage of the block.gaslimit denoted in BPS, used as the gas limit for subscriber calls + // 100 bps is 1% + // at 30M gas, the limit is 300K + uint256 private constant BLOCK_LIMIT_BPS = 100; + + mapping(uint256 tokenId => ISubscriber subscriber) public subscriber; + + function _subscribe(uint256 tokenId, PositionConfig memory config, address newSubscriber) internal { + ISubscriber _subscriber = subscriber[tokenId]; + + if (_subscriber != NO_SUBSCRIBER) revert AlreadySubscribed(address(_subscriber)); + subscriber[tokenId] = ISubscriber(newSubscriber); + + ISubscriber(newSubscriber).notifySubscribe(tokenId, config); + emit Subscribed(tokenId, address(newSubscriber)); + } + + /// @dev Must always allow a user to unsubscribe. In the case of a malicious subscriber, a user can always unsubscribe safely, ensuring liquidity is always modifiable. + function _unsubscribe(uint256 tokenId, PositionConfig memory config) internal { + ISubscriber _subscriber = subscriber[tokenId]; + + uint256 subscriberGasLimit = BLOCK_LIMIT_BPS.toGasLimit(); + + try _subscriber.notifyUnsubscribe{gas: subscriberGasLimit}(tokenId, config) {} catch {} + + delete subscriber[tokenId]; + emit Unsubscribed(tokenId, address(_subscriber)); + } + + function _notifyModifyLiquidity(uint256 tokenId, PositionConfig memory config, int256 liquidityChange) internal { + subscriber[tokenId].notifyModifyLiquidity(tokenId, config, liquidityChange); + } + + function _notifyTransfer(uint256 tokenId, address previousOwner, address newOwner) internal { + subscriber[tokenId].notifyTransfer(tokenId, previousOwner, newOwner); + } +} diff --git a/src/interfaces/IERC721Permit.sol b/src/interfaces/IERC721Permit.sol index 213bca2a..875a5568 100644 --- a/src/interfaces/IERC721Permit.sol +++ b/src/interfaces/IERC721Permit.sol @@ -21,6 +21,7 @@ interface IERC721Permit { /// @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` + /// @dev payable so it can be multicalled with NATIVE related actions function permit(address spender, uint256 tokenId, uint256 deadline, uint256 nonce, uint8 v, bytes32 r, bytes32 s) external payable; diff --git a/src/interfaces/INotifier.sol b/src/interfaces/INotifier.sol new file mode 100644 index 00000000..6fced60e --- /dev/null +++ b/src/interfaces/INotifier.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {PositionConfig} from "../libraries/PositionConfig.sol"; + +/// @notice This interface is used to opt in to sending updates to external contracts about position modifications or transfers +interface INotifier { + /// @notice Enables the subscriber to receive notifications for a respective position + /// @param tokenId the ERC721 tokenId + /// @param config the corresponding PositionConfig for the tokenId + /// @param subscriber the address to notify + /// @dev Calling subscribe when a position is already subscribed will revert + /// @dev payable so it can be multicalled with NATIVE related actions + function subscribe(uint256 tokenId, PositionConfig calldata config, address subscriber) external payable; + + /// @notice Removes the subscriber from receiving notifications for a respective position + /// @param tokenId the ERC721 tokenId + /// @param config the corresponding PositionConfig for the tokenId + /// @dev payable so it can be multicalled with NATIVE related actions + function unsubscribe(uint256 tokenId, PositionConfig calldata config) external payable; + + /// @notice Returns whether a a position should call out to notify a subscribing contract on modification or transfer + /// @param tokenId the ERC721 tokenId + /// @return bool whether or not the position has a subscriber + function hasSubscriber(uint256 tokenId) external view returns (bool); +} diff --git a/src/interfaces/IPositionManager.sol b/src/interfaces/IPositionManager.sol index 371aed93..c2ae7201 100644 --- a/src/interfaces/IPositionManager.sol +++ b/src/interfaces/IPositionManager.sol @@ -4,22 +4,23 @@ pragma solidity ^0.8.24; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; -interface IPositionManager { +import {INotifier} from "./INotifier.sol"; + +interface IPositionManager is INotifier { error NotApproved(address caller); error DeadlinePassed(); error IncorrectPositionConfigForTokenId(uint256 tokenId); error ClearExceedsMaxAmount(Currency currency, int256 amount, uint256 maxAmount); - /// @notice Maps the ERC721 tokenId to a configId, which is a keccak256 hash of the position's pool key, and range (tickLower, tickUpper) - /// Enforces that a minted ERC721 token is tied to one range on one pool. - /// @param tokenId the ERC721 tokenId, assigned at mint - /// @return configId the hash of the position's poolkey, tickLower, and tickUpper - function positionConfigs(uint256 tokenId) external view returns (bytes32 configId); - /// @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 function modifyLiquidities(bytes calldata payload, uint256 deadline) external payable; function nextTokenId() external view returns (uint256); + + /// @param tokenId the ERC721 tokenId + /// @return configId a truncated hash of the position's poolkey, tickLower, and tickUpper + /// @dev truncates the least significant bit of the hash + function getPositionConfigId(uint256 tokenId) external view returns (bytes32 configId); } diff --git a/src/interfaces/ISubscriber.sol b/src/interfaces/ISubscriber.sol new file mode 100644 index 00000000..a74efeeb --- /dev/null +++ b/src/interfaces/ISubscriber.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {PositionConfig} from "../libraries/PositionConfig.sol"; + +interface ISubscriber { + function notifySubscribe(uint256 tokenId, PositionConfig memory config) external; + function notifyUnsubscribe(uint256 tokenId, PositionConfig memory config) external; + function notifyModifyLiquidity(uint256 tokenId, PositionConfig memory config, int256 liquidityChange) external; + function notifyTransfer(uint256 tokenId, address previousOwner, address newOwner) external; +} diff --git a/src/libraries/GasLimitCalculator.sol b/src/libraries/GasLimitCalculator.sol new file mode 100644 index 00000000..d5a8a440 --- /dev/null +++ b/src/libraries/GasLimitCalculator.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +// TODO: Post-audit move to core, as v4-core will use something similar. +library GasLimitCalculator { + uint256 constant BPS_DENOMINATOR = 10_000; + + /// calculates a gas limit as a percentage of the currenct block's gas limit + function toGasLimit(uint256 bps) internal view returns (uint256 gasLimit) { + return block.gaslimit * bps / BPS_DENOMINATOR; + } +} diff --git a/src/libraries/PositionConfig.sol b/src/libraries/PositionConfig.sol index d3bd8e24..7c5d632a 100644 --- a/src/libraries/PositionConfig.sol +++ b/src/libraries/PositionConfig.sol @@ -3,17 +3,58 @@ pragma solidity ^0.8.24; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; -// A PositionConfig is the input for creating and modifying a Position in core, set per tokenId +// A PositionConfig is the input for creating and modifying a Position in core, whose truncated hash is set per tokenId struct PositionConfig { PoolKey poolKey; int24 tickLower; int24 tickUpper; } -/// @notice Library for computing the configId given a PositionConfig +/// @notice Library to get and set the PositionConfigId and subscriber status for a given tokenId library PositionConfigLibrary { + using PositionConfigLibrary for PositionConfig; + + bytes32 constant MASK_UPPER_BIT = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; + bytes32 constant DIRTY_UPPER_BIT = 0x8000000000000000000000000000000000000000000000000000000000000000; + + /// @notice returns the truncated hash of the PositionConfig for a given tokenId + function getConfigId(mapping(uint256 => bytes32) storage positionConfigs, uint256 tokenId) + internal + view + returns (bytes32 configId) + { + configId = positionConfigs[tokenId] & MASK_UPPER_BIT; + } + + function setConfigId( + mapping(uint256 => bytes32) storage positionConfigs, + uint256 tokenId, + PositionConfig calldata config + ) internal { + positionConfigs[tokenId] = config.toId(); + } + + function setSubscribe(mapping(uint256 => bytes32) storage positionConfigs, uint256 tokenId) internal { + positionConfigs[tokenId] |= DIRTY_UPPER_BIT; + } + + function setUnsubscribe(mapping(uint256 => bytes32) storage positionConfigs, uint256 tokenId) internal { + positionConfigs[tokenId] &= MASK_UPPER_BIT; + } + + function hasSubscriber(mapping(uint256 => bytes32) storage positionConfigs, uint256 tokenId) + internal + view + returns (bool subscribed) + { + bytes32 _config = positionConfigs[tokenId]; + assembly ("memory-safe") { + subscribed := shr(255, _config) + } + } + function toId(PositionConfig calldata config) internal pure returns (bytes32 id) { - // id = keccak256(abi.encodePacked(currency0, currency1, fee, tickSpacing, hooks, tickLower, tickUpper))) + // id = keccak256(abi.encodePacked(currency0, currency1, fee, tickSpacing, hooks, tickLower, tickUpper))) >> 1 assembly ("memory-safe") { let fmp := mload(0x40) mstore(add(fmp, 0x34), calldataload(add(config, 0xc0))) // tickUpper: [0x51, 0x54) @@ -24,7 +65,7 @@ library PositionConfigLibrary { mstore(add(fmp, 0x14), calldataload(add(config, 0x20))) // currency1: [0x20, 0x34) mstore(fmp, calldataload(config)) // currency0: [0x0c, 0x20) - id := keccak256(add(fmp, 0x0c), 0x48) // len is 72 bytes + id := shr(1, keccak256(add(fmp, 0x0c), 0x48)) // len is 72 bytes, truncate lower bit of the hash // now clean the memory we used mstore(add(fmp, 0x40), 0) // fmp+0x40 held hooks (14 bytes), tickLower, tickUpper diff --git a/test/libraries/GasLimitCalculator.t.sol b/test/libraries/GasLimitCalculator.t.sol new file mode 100644 index 00000000..47129537 --- /dev/null +++ b/test/libraries/GasLimitCalculator.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {GasLimitCalculator} from "../../src/libraries/GasLimitCalculator.sol"; + +contract GasLimitCalculatorTest is Test { + function test_gasLimit_100_percent() public { + assertEq(block.gaslimit, GasLimitCalculator.toGasLimit(10_000)); + } + + function test_gasLimit_1_percent() public { + /// 100 bps = 1% + // 1% of 3_000_000_000 is 30_000_000 + assertEq(30_000_000, GasLimitCalculator.toGasLimit(100)); + } + + function test_gasLimit_1BP() public { + /// 1bp is 0.01% + assertEq(300_000, GasLimitCalculator.toGasLimit(1)); + } +} diff --git a/test/libraries/PositionConfig.t.sol b/test/libraries/PositionConfig.t.sol index 1eeedc18..cfec1a54 100644 --- a/test/libraries/PositionConfig.t.sol +++ b/test/libraries/PositionConfig.t.sol @@ -2,13 +2,108 @@ pragma solidity ^0.8.24; import "forge-std/Test.sol"; + import {PositionConfig, PositionConfigLibrary} from "../../src/libraries/PositionConfig.sol"; contract PositionConfigTest is Test { - using PositionConfigLibrary for PositionConfig; + using PositionConfigLibrary for *; + + mapping(uint256 => bytes32) internal testConfigs; + + bytes32 public constant UPPER_BIT_SET = 0x8000000000000000000000000000000000000000000000000000000000000000; function test_fuzz_toId(PositionConfig calldata config) public pure { - bytes32 expectedId = keccak256( + bytes32 expectedId = _calculateExpectedId(config); + assertEq(expectedId, config.toId()); + } + + function test_fuzz_setConfigId(uint256 tokenId, PositionConfig calldata config) public { + testConfigs.setConfigId(tokenId, config); + + bytes32 expectedConfigId = _calculateExpectedId(config); + + bytes32 actualConfigId = testConfigs[tokenId]; + assertEq(expectedConfigId, actualConfigId); + } + + function test_fuzz_getConfigId(uint256 tokenId, PositionConfig calldata config) public { + bytes32 expectedId = _calculateExpectedId(config); + // set + testConfigs[tokenId] = expectedId; + + assertEq(expectedId, testConfigs.getConfigId(tokenId)); + } + + function test_fuzz_setConfigId_getConfigId(uint256 tokenId, PositionConfig calldata config) public { + testConfigs.setConfigId(tokenId, config); + + bytes32 expectedId = _calculateExpectedId(config); + + assertEq(testConfigs.getConfigId(tokenId), testConfigs[tokenId]); + assertEq(testConfigs.getConfigId(tokenId), expectedId); + } + + function test_fuzz_getConfigId_equal_afterSubscribe(uint256 tokenId, PositionConfig calldata config) public { + testConfigs.setConfigId(tokenId, config); + testConfigs.setSubscribe(tokenId); + + assertEq(testConfigs.getConfigId(tokenId), config.toId()); + } + + function test_fuzz_setSubscribe(uint256 tokenId) public { + testConfigs.setSubscribe(tokenId); + bytes32 upperBitSet = testConfigs[tokenId]; + + assertEq(upperBitSet, UPPER_BIT_SET); + } + + function test_fuzz_setConfigId_setSubscribe(uint256 tokenId, PositionConfig calldata config) public { + testConfigs.setConfigId(tokenId, config); + testConfigs.setSubscribe(tokenId); + + bytes32 expectedConfig = _calculateExpectedId(config) | UPPER_BIT_SET; + + bytes32 _config = testConfigs[tokenId]; + + assertEq(_config, expectedConfig); + } + + function test_fuzz_setUnsubscribe(uint256 tokenId) public { + testConfigs.setSubscribe(tokenId); + bytes32 _config = testConfigs[tokenId]; + assertEq(_config, UPPER_BIT_SET); + testConfigs.setUnsubscribe(tokenId); + _config = testConfigs[tokenId]; + assertEq(_config, 0); + } + + function test_hasSubscriber(uint256 tokenId) public { + testConfigs.setSubscribe(tokenId); + assert(testConfigs.hasSubscriber(tokenId)); + testConfigs.setUnsubscribe(tokenId); + assert(!testConfigs.hasSubscriber(tokenId)); + } + + function test_fuzz_setConfigId_setSubscribe_setUnsubscribe_getConfigId( + uint256 tokenId, + PositionConfig calldata config + ) public { + assertEq(testConfigs.getConfigId(tokenId), 0); + + testConfigs.setConfigId(tokenId, config); + assertEq(testConfigs.getConfigId(tokenId), config.toId()); + + testConfigs.setSubscribe(tokenId); + assertEq(testConfigs.getConfigId(tokenId), config.toId()); + assertEq(testConfigs.hasSubscriber(tokenId), true); + + testConfigs.setUnsubscribe(tokenId); + assertEq(testConfigs.getConfigId(tokenId), config.toId()); + assertEq(testConfigs.hasSubscriber(tokenId), false); + } + + function _calculateExpectedId(PositionConfig calldata config) internal pure returns (bytes32 expectedId) { + expectedId = keccak256( abi.encodePacked( config.poolKey.currency0, config.poolKey.currency1, @@ -19,6 +114,7 @@ contract PositionConfigTest is Test { config.tickUpper ) ); - assertEq(expectedId, config.toId()); + // truncate the upper bit + expectedId = expectedId >> 1; } } diff --git a/test/mocks/MockBadSubscribers.sol b/test/mocks/MockBadSubscribers.sol new file mode 100644 index 00000000..2fa517a1 --- /dev/null +++ b/test/mocks/MockBadSubscribers.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.20; + +import {ISubscriber} from "../../src/interfaces/ISubscriber.sol"; +import {PositionConfig} from "../../src/libraries/PositionConfig.sol"; +import {PositionManager} from "../../src/PositionManager.sol"; + +/// @notice A subscriber contract that returns values from the subscriber entrypoints +contract MockReturnDataSubscriber is ISubscriber { + PositionManager posm; + + uint256 public notifySubscribeCount; + uint256 public notifyUnsubscribeCount; + uint256 public notifyModifyLiquidityCount; + uint256 public notifyTransferCount; + + error NotAuthorizedNotifer(address sender); + + error NotImplemented(); + + uint256 memPtr; + + constructor(PositionManager _posm) { + posm = _posm; + } + + modifier onlyByPosm() { + if (msg.sender != address(posm)) revert NotAuthorizedNotifer(msg.sender); + _; + } + + function notifySubscribe(uint256 tokenId, PositionConfig memory config) external onlyByPosm { + notifySubscribeCount++; + } + + function notifyUnsubscribe(uint256 tokenId, PositionConfig memory config) external onlyByPosm { + notifyUnsubscribeCount++; + uint256 _memPtr = memPtr; + assembly { + let fmp := mload(0x40) + mstore(fmp, 0xBEEF) + mstore(add(fmp, 0x20), 0xCAFE) + return(fmp, _memPtr) + } + } + + function notifyModifyLiquidity(uint256 tokenId, PositionConfig memory config, int256 liquidityChange) + external + onlyByPosm + { + notifyModifyLiquidityCount++; + } + + function notifyTransfer(uint256 tokenId, address previousOwner, address newOwner) external onlyByPosm { + notifyTransferCount++; + } + + function setReturnDataSize(uint256 _value) external { + memPtr = _value; + } +} diff --git a/test/mocks/MockSubscriber.sol b/test/mocks/MockSubscriber.sol new file mode 100644 index 00000000..b6c92cad --- /dev/null +++ b/test/mocks/MockSubscriber.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.20; + +import {ISubscriber} from "../../src/interfaces/ISubscriber.sol"; +import {PositionConfig} from "../../src/libraries/PositionConfig.sol"; +import {PositionManager} from "../../src/PositionManager.sol"; + +/// @notice A subscriber contract that ingests updates from the v4 position manager +contract MockSubscriber is ISubscriber { + PositionManager posm; + + uint256 public notifySubscribeCount; + uint256 public notifyUnsubscribeCount; + uint256 public notifyModifyLiquidityCount; + uint256 public notifyTransferCount; + + error NotAuthorizedNotifer(address sender); + + error NotImplemented(); + + constructor(PositionManager _posm) { + posm = _posm; + } + + modifier onlyByPosm() { + if (msg.sender != address(posm)) revert NotAuthorizedNotifer(msg.sender); + _; + } + + function notifySubscribe(uint256 tokenId, PositionConfig memory config) external onlyByPosm { + notifySubscribeCount++; + } + + function notifyUnsubscribe(uint256 tokenId, PositionConfig memory config) external onlyByPosm { + notifyUnsubscribeCount++; + } + + function notifyModifyLiquidity(uint256 tokenId, PositionConfig memory config, int256 liquidityChange) + external + onlyByPosm + { + notifyModifyLiquidityCount++; + } + + function notifyTransfer(uint256 tokenId, address previousOwner, address newOwner) external onlyByPosm { + notifyTransferCount++; + } +} diff --git a/test/position-managers/NativeToken.t.sol b/test/position-managers/NativeToken.t.sol index 7d03721c..74e89af9 100644 --- a/test/position-managers/NativeToken.t.sol +++ b/test/position-managers/NativeToken.t.sol @@ -28,6 +28,7 @@ import {Actions} from "../../src/libraries/Actions.sol"; import {PositionManager} from "../../src/PositionManager.sol"; import {Constants} from "../../src/libraries/Constants.sol"; +import {MockSubscriber} from "../mocks/MockSubscriber.sol"; import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; import {Planner, Plan} from "../shared/Planner.sol"; @@ -44,6 +45,8 @@ contract PositionManagerTest is Test, PosmTestSetup, LiquidityFuzzers { PoolId poolId; + MockSubscriber sub; + function setUp() public { deployFreshManagerAndRouters(); deployMintAndApprove2Currencies(); @@ -58,6 +61,8 @@ contract PositionManagerTest is Test, PosmTestSetup, LiquidityFuzzers { // currency0 is the native token so only execute approvals for currency1. approvePosmCurrency(currency1); + sub = new MockSubscriber(lpm); + vm.deal(address(this), type(uint256).max); } @@ -505,4 +510,35 @@ contract PositionManagerTest is Test, PosmTestSetup, LiquidityFuzzers { assertEq(currency0.balanceOfSelf() - balance0Before, uint128(delta.amount0())); assertEq(currency1.balanceOfSelf() - balance1Before, uint128(delta.amount1())); } + + // this test fails unless subscribe is payable + function test_multicall_mint_subscribe_native() public { + uint256 tokenId = lpm.nextTokenId(); + + PositionConfig memory config = PositionConfig({poolKey: nativeKey, tickLower: -60, tickUpper: 60}); + + Plan memory plan = Planner.init(); + plan.add( + Actions.MINT_POSITION, + abi.encode(config, 100e18, MAX_SLIPPAGE_INCREASE, MAX_SLIPPAGE_INCREASE, address(this), ZERO_BYTES) + ); + plan.add(Actions.CLOSE_CURRENCY, abi.encode(config.poolKey.currency0)); + plan.add(Actions.CLOSE_CURRENCY, abi.encode(config.poolKey.currency1)); + plan.add(Actions.SWEEP, abi.encode(CurrencyLibrary.NATIVE, address(this))); + bytes memory actions = plan.encode(); + + bytes[] memory calls = new bytes[](2); + + calls[0] = abi.encodeWithSelector(lpm.modifyLiquidities.selector, actions, _deadline); + calls[1] = abi.encodeWithSelector(lpm.subscribe.selector, tokenId, config, sub); + + lpm.multicall{value: 10e18}(calls); + + bytes32 positionId = + Position.calculatePositionKey(address(lpm), config.tickLower, config.tickUpper, bytes32(tokenId)); + (uint256 liquidity,,) = manager.getPositionInfo(config.poolKey.toId(), positionId); + + assertEq(liquidity, 100e18); + assertEq(sub.notifySubscribeCount(), 1); + } } diff --git a/test/position-managers/PositionManager.notifier.t.sol b/test/position-managers/PositionManager.notifier.t.sol new file mode 100644 index 00000000..a1544f13 --- /dev/null +++ b/test/position-managers/PositionManager.notifier.t.sol @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Position} from "@uniswap/v4-core/src/libraries/Position.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {PosmTestSetup} from "../shared/PosmTestSetup.sol"; +import {MockSubscriber} from "../mocks/MockSubscriber.sol"; +import {ISubscriber} from "../../src/interfaces/ISubscriber.sol"; +import {PositionConfig} from "../../src/libraries/PositionConfig.sol"; +import {IPositionManager} from "../../src/interfaces/IPositionManager.sol"; +import {Plan, Planner} from "../shared/Planner.sol"; +import {Actions} from "../../src/libraries/Actions.sol"; +import {MockReturnDataSubscriber} from "../mocks/MockBadSubscribers.sol"; + +contract PositionManagerNotifierTest is Test, PosmTestSetup, GasSnapshot { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + using Planner for Plan; + + MockSubscriber sub; + MockReturnDataSubscriber badSubscriber; + PositionConfig config; + + address alice = makeAddr("ALICE"); + address bob = makeAddr("BOB"); + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + (key,) = initPool(currency0, currency1, IHooks(hook), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + + // Requires currency0 and currency1 to be set in base Deployers contract. + deployAndApprovePosm(manager); + + sub = new MockSubscriber(lpm); + badSubscriber = new MockReturnDataSubscriber(lpm); + config = PositionConfig({poolKey: key, tickLower: -300, tickUpper: 300}); + + // TODO: Test NATIVE poolKey + } + + function test_subscribe_revertsWithEmptyPositionConfig() public { + uint256 tokenId = lpm.nextTokenId(); + vm.expectRevert("NOT_MINTED"); + lpm.subscribe(tokenId, config, address(sub)); + } + + function test_subscribe_revertsWhenNotApproved() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // this contract is not approved to operate on alice's liq + + vm.expectRevert(abi.encodeWithSelector(IPositionManager.NotApproved.selector, address(this))); + lpm.subscribe(tokenId, config, address(sub)); + } + + function test_subscribe_reverts_withIncorrectConfig() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // approve this contract to operate on alices liq + vm.startPrank(alice); + lpm.approve(address(this), tokenId); + vm.stopPrank(); + + PositionConfig memory incorrectConfig = PositionConfig({poolKey: key, tickLower: -300, tickUpper: 301}); + + vm.expectRevert(abi.encodeWithSelector(IPositionManager.IncorrectPositionConfigForTokenId.selector, tokenId)); + lpm.subscribe(tokenId, incorrectConfig, address(sub)); + } + + function test_subscribe_succeeds() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // approve this contract to operate on alices liq + vm.startPrank(alice); + lpm.approve(address(this), tokenId); + vm.stopPrank(); + + lpm.subscribe(tokenId, config, address(sub)); + + assertEq(lpm.hasSubscriber(tokenId), true); + assertEq(address(lpm.subscriber(tokenId)), address(sub)); + assertEq(sub.notifySubscribeCount(), 1); + } + + function test_notifyModifyLiquidity_succeeds() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // approve this contract to operate on alices liq + vm.startPrank(alice); + lpm.approve(address(this), tokenId); + vm.stopPrank(); + + lpm.subscribe(tokenId, config, address(sub)); + + assertEq(lpm.hasSubscriber(tokenId), true); + assertEq(address(lpm.subscriber(tokenId)), address(sub)); + + Plan memory plan = Planner.init(); + for (uint256 i = 0; i < 10; i++) { + plan.add( + Actions.INCREASE_LIQUIDITY, + abi.encode(tokenId, config, 10e18, MAX_SLIPPAGE_INCREASE, MAX_SLIPPAGE_INCREASE, ZERO_BYTES) + ); + } + + bytes memory calls = plan.finalizeModifyLiquidityWithSettlePair(config.poolKey); + lpm.modifyLiquidities(calls, _deadline); + + assertEq(sub.notifySubscribeCount(), 1); + assertEq(sub.notifyModifyLiquidityCount(), 10); + } + + function test_notifyTransfer_withTransferFrom_succeeds() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // approve this contract to operate on alices liq + vm.startPrank(alice); + lpm.approve(address(this), tokenId); + vm.stopPrank(); + + lpm.subscribe(tokenId, config, address(sub)); + + assertEq(lpm.hasSubscriber(tokenId), true); + assertEq(address(lpm.subscriber(tokenId)), address(sub)); + + lpm.transferFrom(alice, bob, tokenId); + + assertEq(sub.notifyTransferCount(), 1); + } + + function test_notifyTransfer_withSafeTransferFrom_succeeds() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // approve this contract to operate on alices liq + vm.startPrank(alice); + lpm.approve(address(this), tokenId); + vm.stopPrank(); + + lpm.subscribe(tokenId, config, address(sub)); + + assertEq(lpm.hasSubscriber(tokenId), true); + assertEq(address(lpm.subscriber(tokenId)), address(sub)); + + lpm.safeTransferFrom(alice, bob, tokenId); + + assertEq(sub.notifyTransferCount(), 1); + } + + function test_notifyTransfer_withSafeTransferFromData_succeeds() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // approve this contract to operate on alices liq + vm.startPrank(alice); + lpm.approve(address(this), tokenId); + vm.stopPrank(); + + lpm.subscribe(tokenId, config, address(sub)); + + assertEq(lpm.hasSubscriber(tokenId), true); + assertEq(address(lpm.subscriber(tokenId)), address(sub)); + + lpm.safeTransferFrom(alice, bob, tokenId, ""); + + assertEq(sub.notifyTransferCount(), 1); + } + + function test_unsubscribe_succeeds() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // approve this contract to operate on alices liq + vm.startPrank(alice); + lpm.approve(address(this), tokenId); + vm.stopPrank(); + + lpm.subscribe(tokenId, config, address(sub)); + + lpm.unsubscribe(tokenId, config); + + assertEq(sub.notifyUnsubscribeCount(), 1); + assertEq(lpm.hasSubscriber(tokenId), false); + assertEq(address(lpm.subscriber(tokenId)), address(0)); + } + + function test_unsubscribe_isSuccessfulWithBadSubscriber() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // approve this contract to operate on alices liq + vm.startPrank(alice); + lpm.approve(address(this), tokenId); + vm.stopPrank(); + + lpm.subscribe(tokenId, config, address(badSubscriber)); + + MockReturnDataSubscriber(badSubscriber).setReturnDataSize(0x600000); + lpm.unsubscribe(tokenId, config); + + // the subscriber contract call failed bc it used too much gas + assertEq(MockReturnDataSubscriber(badSubscriber).notifyUnsubscribeCount(), 0); + assertEq(lpm.hasSubscriber(tokenId), false); + assertEq(address(lpm.subscriber(tokenId)), address(0)); + } + + function test_multicall_mint_subscribe() public { + uint256 tokenId = lpm.nextTokenId(); + + Plan memory plan = Planner.init(); + plan.add( + Actions.MINT_POSITION, + abi.encode(config, 100e18, MAX_SLIPPAGE_INCREASE, MAX_SLIPPAGE_INCREASE, address(this), ZERO_BYTES) + ); + bytes memory actions = plan.finalizeModifyLiquidityWithSettlePair(config.poolKey); + + bytes[] memory calls = new bytes[](2); + + calls[0] = abi.encodeWithSelector(lpm.modifyLiquidities.selector, actions, _deadline); + calls[1] = abi.encodeWithSelector(lpm.subscribe.selector, tokenId, config, sub); + + lpm.multicall(calls); + + bytes32 positionId = + Position.calculatePositionKey(address(lpm), config.tickLower, config.tickUpper, bytes32(tokenId)); + (uint256 liquidity,,) = manager.getPositionInfo(config.poolKey.toId(), positionId); + + assertEq(liquidity, 100e18); + assertEq(sub.notifySubscribeCount(), 1); + + assertEq(lpm.hasSubscriber(tokenId), true); + assertEq(address(lpm.subscriber(tokenId)), address(sub)); + } + + function test_multicall_mint_subscribe_increase() public { + uint256 tokenId = lpm.nextTokenId(); + + // Encode mint. + Plan memory plan = Planner.init(); + plan.add( + Actions.MINT_POSITION, + abi.encode(config, 100e18, MAX_SLIPPAGE_INCREASE, MAX_SLIPPAGE_INCREASE, address(this), ZERO_BYTES) + ); + bytes memory actions = plan.finalizeModifyLiquidityWithSettlePair(config.poolKey); + + // Encode increase separately. + plan = Planner.init(); + plan.add( + Actions.INCREASE_LIQUIDITY, + abi.encode(tokenId, config, 10e18, MAX_SLIPPAGE_INCREASE, MAX_SLIPPAGE_INCREASE, ZERO_BYTES) + ); + bytes memory actions2 = plan.finalizeModifyLiquidityWithSettlePair(config.poolKey); + + bytes[] memory calls = new bytes[](3); + + calls[0] = abi.encodeWithSelector(lpm.modifyLiquidities.selector, actions, _deadline); + calls[1] = abi.encodeWithSelector(lpm.subscribe.selector, tokenId, config, sub); + calls[2] = abi.encodeWithSelector(lpm.modifyLiquidities.selector, actions2, _deadline); + + lpm.multicall(calls); + + bytes32 positionId = + Position.calculatePositionKey(address(lpm), config.tickLower, config.tickUpper, bytes32(tokenId)); + (uint256 liquidity,,) = manager.getPositionInfo(config.poolKey.toId(), positionId); + + assertEq(liquidity, 110e18); + assertEq(sub.notifySubscribeCount(), 1); + assertEq(sub.notifyModifyLiquidityCount(), 1); + assertEq(lpm.hasSubscriber(tokenId), true); + assertEq(address(lpm.subscriber(tokenId)), address(sub)); + } + + function test_unsubscribe_revertsWhenNotSubscribed() public { + uint256 tokenId = lpm.nextTokenId(); + mint(config, 100e18, alice, ZERO_BYTES); + + // approve this contract to operate on alices liq + vm.startPrank(alice); + lpm.approve(address(this), tokenId); + vm.stopPrank(); + + vm.expectRevert(); + lpm.unsubscribe(tokenId, config); + } +}