Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

check pool manager lock on transfer, subscribe, and unsubscribe #379

Merged
merged 8 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_subscribe.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
84348
88168
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_unsubscribe.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
59260
63080
9 changes: 8 additions & 1 deletion src/PositionManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ contract PositionManager is
_;
}

/// @notice Enforces that the PoolManager is locked.
modifier onlyIfPoolManagerLocked() override {
if (poolManager.isUnlocked()) revert PoolManagerMustBeLocked();
_;
}

function tokenURI(uint256 tokenId) public view override returns (string memory) {
return IPositionDescriptor(tokenDescriptor).tokenURI(this, tokenId);
}
Expand Down Expand Up @@ -444,7 +450,8 @@ contract PositionManager is
}

/// @dev overrides solmate transferFrom in case a notification to subscribers is needed
function transferFrom(address from, address to, uint256 id) public virtual override {
/// @dev will revert if pool manager is locked
function transferFrom(address from, address to, uint256 id) public virtual override onlyIfPoolManagerLocked {
super.transferFrom(from, to, id);
if (positionInfo[id].hasSubscriber()) _notifyTransfer(id, from, to);
}
Expand Down
11 changes: 10 additions & 1 deletion src/base/Notifier.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ abstract contract Notifier is INotifier {
/// @param tokenId the tokenId of the position
modifier onlyIfApproved(address caller, uint256 tokenId) virtual;

/// @notice Enforces that the PoolManager is locked.
modifier onlyIfPoolManagerLocked() virtual;

function _setUnsubscribed(uint256 tokenId) internal virtual;

function _setSubscribed(uint256 tokenId) internal virtual;
Expand All @@ -37,6 +40,7 @@ abstract contract Notifier is INotifier {
function subscribe(uint256 tokenId, address newSubscriber, bytes calldata data)
external
payable
onlyIfPoolManagerLocked
onlyIfApproved(msg.sender, tokenId)
{
ISubscriber _subscriber = subscriber[tokenId];
Expand All @@ -56,7 +60,12 @@ abstract contract Notifier is INotifier {
}

/// @inheritdoc INotifier
function unsubscribe(uint256 tokenId) external payable onlyIfApproved(msg.sender, tokenId) {
function unsubscribe(uint256 tokenId)
external
payable
onlyIfPoolManagerLocked
onlyIfApproved(msg.sender, tokenId)
{
_unsubscribe(tokenId);
}

Expand Down
2 changes: 2 additions & 0 deletions src/interfaces/INotifier.sol
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ interface INotifier {
/// @param data caller-provided data that's forwarded to the subscriber contract
/// @dev Calling subscribe when a position is already subscribed will revert
/// @dev payable so it can be multicalled with NATIVE related actions
/// @dev will revert if pool manager is locked
function subscribe(uint256 tokenId, address newSubscriber, bytes calldata data) external payable;

/// @notice Removes the subscriber from receiving notifications for a respective position
/// @param tokenId the ERC721 tokenId
/// @dev Callers must specify a high gas limit (remaining gas should be higher than unsubscriberGasLimit) such that the subscriber can be notified
/// @dev payable so it can be multicalled with NATIVE related actions
/// @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.
/// @dev will revert if pool manager is locked
function unsubscribe(uint256 tokenId) external payable;

/// @notice Returns and determines the maximum allowable gas-used for notifying unsubscribe
Expand Down
3 changes: 3 additions & 0 deletions src/interfaces/IPositionManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ interface IPositionManager is INotifier, IImmutableState {
error NotApproved(address caller);
/// @notice Thrown when the block.timestamp exceeds the user-provided deadline
error DeadlinePassed(uint256 deadline);
/// @notice Thrown when calling transfer, subscribe, or unsubscribe when the PoolManager is unlocked.
/// @dev This is to prevent hooks from being able to trigger notifications at the same time the position is being modified.
error PoolManagerMustBeLocked();

/// @notice Unlocks Uniswap v4 PoolManager and batches actions for modifying liquidity
/// @dev This is the standard entrypoint for the PositionManager
Expand Down
37 changes: 37 additions & 0 deletions test/mocks/MockReenterHook.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
import {BaseTestHooks} from "@uniswap/v4-core/src/test/BaseTestHooks.sol";
import {PositionManager} from "../../src/PositionManager.sol";

contract MockReenterHook is BaseTestHooks {
PositionManager posm;

function beforeAddLiquidity(
address,
PoolKey calldata,
IPoolManager.ModifyLiquidityParams calldata,
bytes calldata functionSelector
) external override returns (bytes4) {
if (functionSelector.length == 0) {
return this.beforeAddLiquidity.selector;
}
(bytes4 selector, address owner, uint256 tokenId) = abi.decode(functionSelector, (bytes4, address, uint256));

if (selector == posm.transferFrom.selector) {
posm.transferFrom(owner, address(this), tokenId);
} else if (selector == posm.subscribe.selector) {
posm.subscribe(tokenId, address(this), "");
} else if (selector == posm.unsubscribe.selector) {
posm.unsubscribe(tokenId);
}
return this.beforeAddLiquidity.selector;
}

function setPosm(PositionManager _posm) external {
posm = _posm;
}
}
81 changes: 81 additions & 0 deletions test/position-managers/PositionManager.notifier.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol";
import {PosmTestSetup} from "../shared/PosmTestSetup.sol";
import {MockSubscriber} from "../mocks/MockSubscriber.sol";
Expand All @@ -20,6 +21,7 @@ import {Actions} from "../../src/libraries/Actions.sol";
import {INotifier} from "../../src/interfaces/INotifier.sol";
import {MockReturnDataSubscriber, MockRevertSubscriber} from "../mocks/MockBadSubscribers.sol";
import {PositionInfoLibrary, PositionInfo} from "../../src/libraries/PositionInfoLibrary.sol";
import {MockReenterHook} from "../mocks/MockReenterHook.sol";

contract PositionManagerNotifierTest is Test, PosmTestSetup, GasSnapshot {
using PoolIdLibrary for PoolKey;
Expand All @@ -31,10 +33,13 @@ contract PositionManagerNotifierTest is Test, PosmTestSetup, GasSnapshot {
MockReturnDataSubscriber badSubscriber;
PositionConfig config;
MockRevertSubscriber revertSubscriber;
MockReenterHook reenterHook;

address alice = makeAddr("ALICE");
address bob = makeAddr("BOB");

PositionConfig reenterConfig;

function setUp() public {
deployFreshManagerAndRouters();
deployMintAndApprove2Currencies();
Expand All @@ -49,6 +54,17 @@ contract PositionManagerNotifierTest is Test, PosmTestSetup, GasSnapshot {
revertSubscriber = new MockRevertSubscriber(lpm);
config = PositionConfig({poolKey: key, tickLower: -300, tickUpper: 300});

// set the reenter hook
MockReenterHook impl = new MockReenterHook();
address hookAddr = payable(address(uint160(Hooks.BEFORE_ADD_LIQUIDITY_FLAG)));
vm.etch(hookAddr, address(impl).code);
reenterHook = MockReenterHook(hookAddr);
reenterHook.setPosm(lpm);

PoolKey memory reenterKey = PoolKey(currency0, currency1, 3000, 60, IHooks(reenterHook));
manager.initialize(reenterKey, SQRT_PRICE_1_1);
reenterConfig = PositionConfig({poolKey: reenterKey, tickLower: -60, tickUpper: 60});

// TODO: Test NATIVE poolKey
}

Expand Down Expand Up @@ -647,4 +663,69 @@ contract PositionManagerNotifierTest is Test, PosmTestSetup, GasSnapshot {
assertEq(sub.notifyUnsubscribeCount(), beforeUnsubCount + 1);
}
}

function test_unsubscribe_reverts_PoolManagerMustBeLocked() public {
uint256 tokenId = lpm.nextTokenId();
mint(reenterConfig, 10e18, address(this), ZERO_BYTES);

bytes memory hookData = abi.encode(lpm.unsubscribe.selector, address(this), tokenId);
bytes memory actions = getMintEncoded(reenterConfig, 10e18, address(this), hookData);

// approve hook as it should not revert because it does not have permissions
lpm.approve(address(reenterHook), tokenId);
// subscribe as it should not revert because there is no subscriber
lpm.subscribe(tokenId, address(sub), ZERO_BYTES);

// should revert since the pool manager is unlocked
vm.expectRevert(
abi.encodeWithSelector(
Hooks.Wrap__FailedHookCall.selector,
address(reenterHook),
abi.encodeWithSelector(IPositionManager.PoolManagerMustBeLocked.selector)
)
);
lpm.modifyLiquidities(actions, _deadline);
}

function test_subscribe_reverts_PoolManagerMustBeLocked() public {
uint256 tokenId = lpm.nextTokenId();
mint(reenterConfig, 10e18, address(this), ZERO_BYTES);

bytes memory hookData = abi.encode(lpm.subscribe.selector, address(this), tokenId);
bytes memory actions = getMintEncoded(reenterConfig, 10e18, address(this), hookData);

// approve hook as it should not revert because it does not have permissions
lpm.approve(address(reenterHook), tokenId);

// should revert since the pool manager is unlocked
vm.expectRevert(
abi.encodeWithSelector(
Hooks.Wrap__FailedHookCall.selector,
address(reenterHook),
abi.encodeWithSelector(IPositionManager.PoolManagerMustBeLocked.selector)
)
);
lpm.modifyLiquidities(actions, _deadline);
}

function test_transferFrom_reverts_PoolManagerMustBeLocked() public {
uint256 tokenId = lpm.nextTokenId();
mint(reenterConfig, 10e18, address(this), ZERO_BYTES);

bytes memory hookData = abi.encode(lpm.transferFrom.selector, address(this), tokenId);
bytes memory actions = getMintEncoded(reenterConfig, 10e18, address(this), hookData);

// approve hook as it should not revert because it does not have permissions
lpm.approve(address(reenterHook), tokenId);

// should revert since the pool manager is unlocked
vm.expectRevert(
abi.encodeWithSelector(
Hooks.Wrap__FailedHookCall.selector,
address(reenterHook),
abi.encodeWithSelector(IPositionManager.PoolManagerMustBeLocked.selector)
)
);
lpm.modifyLiquidities(actions, _deadline);
}
}
Loading