diff --git a/snapshots/PermissionedV4RouterTest.json b/snapshots/PermissionedV4RouterTest.json new file mode 100644 index 000000000..7239323da --- /dev/null +++ b/snapshots/PermissionedV4RouterTest.json @@ -0,0 +1,3 @@ +{ + "PermissionedV4Router_ExactInputSingle_PermissionedTokens": "231245" +} \ No newline at end of file diff --git a/src/PositionManager.sol b/src/PositionManager.sol index 7c50f170f..c38920953 100644 --- a/src/PositionManager.sol +++ b/src/PositionManager.sol @@ -358,7 +358,7 @@ contract PositionManager is uint128 amount1Max, address owner, bytes calldata hookData - ) internal { + ) internal virtual { // mint receipt token uint256 tokenId; // tokenId is assigned to current nextTokenId before incrementing it @@ -519,7 +519,7 @@ contract PositionManager is } // implementation of abstract function DeltaResolver._pay - function _pay(Currency currency, address payer, uint256 amount) internal override { + function _pay(Currency currency, address payer, uint256 amount) internal virtual override { if (payer == address(this)) { currency.transfer(address(poolManager), amount); } else { diff --git a/src/base/DeltaResolver.sol b/src/base/DeltaResolver.sol index bd0d39bf0..7646e6006 100644 --- a/src/base/DeltaResolver.sol +++ b/src/base/DeltaResolver.sol @@ -76,7 +76,7 @@ abstract contract DeltaResolver is ImmutableState { } /// @notice Calculates the amount for a settle action - function _mapSettleAmount(uint256 amount, Currency currency) internal view returns (uint256) { + function _mapSettleAmount(uint256 amount, Currency currency) internal view virtual returns (uint256) { if (amount == ActionConstants.CONTRACT_BALANCE) { return currency.balanceOfSelf(); } else if (amount == ActionConstants.OPEN_DELTA) { diff --git a/src/hooks/permissionedPools/BaseAllowListChecker.sol b/src/hooks/permissionedPools/BaseAllowListChecker.sol new file mode 100644 index 000000000..642d2fdc0 --- /dev/null +++ b/src/hooks/permissionedPools/BaseAllowListChecker.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IAllowlistChecker, PermissionFlag, IERC165} from "./interfaces/IAllowlistChecker.sol"; +import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; + +abstract contract BaseAllowlistChecker is IAllowlistChecker, ERC165 { + function checkAllowlist(address account) public view virtual returns (PermissionFlag); + + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IAllowlistChecker).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/src/hooks/permissionedPools/PermissionedPositionManager.sol b/src/hooks/permissionedPools/PermissionedPositionManager.sol new file mode 100644 index 000000000..49bc3187e --- /dev/null +++ b/src/hooks/permissionedPools/PermissionedPositionManager.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { + PositionManager, + PoolKey, + IPoolManager, + IAllowanceTransfer, + IPositionDescriptor, + IWETH9, + Currency +} from "../../PositionManager.sol"; +import {IPermissionsAdapter} from "./interfaces/IPermissionsAdapter.sol"; +import {IPermissionsAdapterFactory} from "./interfaces/IPermissionsAdapterFactory.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {PermissionFlags} from "./libraries/PermissionFlags.sol"; +import {ActionConstants} from "../../libraries/ActionConstants.sol"; + +contract PermissionedPositionManager is PositionManager { + IPermissionsAdapterFactory public immutable PERMISSIONS_ADAPTER_FACTORY; + + mapping(Currency currency => mapping(IHooks hooks => bool)) public isAllowedHooks; + + event AllowedHooksUpdated(Currency currency, IHooks hooks, bool allowed); + + error InvalidHook(); + error SafeTransferDisabled(); + error NotPermissionsAdapterAdmin(); + + /// @dev as this contract must know the hooks address in advance, it must be passed in as a constructor argument + constructor( + IPoolManager _poolManager, + IAllowanceTransfer _permit2, + uint256 _unsubscribeGasLimit, + IPositionDescriptor _tokenDescriptor, + IWETH9 _weth9, + IPermissionsAdapterFactory _permissionsAdapterFactory + ) PositionManager(_poolManager, _permit2, _unsubscribeGasLimit, _tokenDescriptor, _weth9) { + PERMISSIONS_ADAPTER_FACTORY = _permissionsAdapterFactory; + } + + /// @notice Sets the allowed hook for a given permissions adapter + /// @dev Sets which hooks are allowed to be used with a permissions adapter. Only callable by the owner of the permissions adapter + /// @param currency The currency of the permissions adapter + /// @param hooks The hook to set the allowance for + /// @param allowed Whether the hook is allowed to be used with the permissions adapter + function setAllowedHook(Currency currency, IHooks hooks, bool allowed) external { + if (_getOwner(currency) != msg.sender) { + revert NotPermissionsAdapterAdmin(); + } + bool oldAllowed = isAllowedHooks[currency][hooks]; + if (oldAllowed == allowed) return; + isAllowedHooks[currency][hooks] = allowed; + emit AllowedHooksUpdated(currency, hooks, allowed); + } + + /// @inheritdoc PositionManager + /// @dev Only allow admins of permissioned tokens to transfer positions that contain their tokens + function transferFrom(address from, address to, uint256 id) public override onlyIfPoolManagerLocked { + (PoolKey memory poolKey,) = getPoolAndPositionInfo(id); + address admin1 = _getOwner(poolKey.currency0); + address admin2 = _getOwner(poolKey.currency1); + if (msg.sender != admin1 && msg.sender != admin2) { + revert Unauthorized(); + } + getApproved[id] = msg.sender; + super.transferFrom(from, to, id); + } + + function safeTransferFrom(address, address, uint256) public pure override { + revert SafeTransferDisabled(); + } + + function safeTransferFrom(address, address, uint256, bytes calldata) public pure override { + revert SafeTransferDisabled(); + } + + /// @dev When minting a position, verify that the sender is allowed to mint the position. This prevents a disallowed user from minting one sided liquidity. + function _mint( + PoolKey calldata poolKey, + int24 tickLower, + int24 tickUpper, + uint256 liquidity, + uint128 amount0Max, + uint128 amount1Max, + address owner, + bytes calldata hookData + ) internal override { + // allowlist is verified in the hook call + if (!_checkAllowedHooks(poolKey)) revert InvalidHook(); + _checkRecipientAllowed(poolKey.currency0, owner); + _checkRecipientAllowed(poolKey.currency1, owner); + super._mint(poolKey, tickLower, tickUpper, liquidity, amount0Max, amount1Max, owner, hookData); + } + + function _checkRecipientAllowed(Currency currency, address recipient) internal view { + address permissionedToken = _verifiedPermissionedTokenOf(currency); + if (permissionedToken == address(0)) return; + if (!IPermissionsAdapter(Currency.unwrap(currency)).isAllowed(recipient, PermissionFlags.LIQUIDITY_ALLOWED)) { + revert Unauthorized(); + } + } + + function _checkAllowedHooks(PoolKey calldata poolKey) internal view returns (bool) { + return + _checkAllowedHook(poolKey.currency0, poolKey.hooks) && _checkAllowedHook(poolKey.currency1, poolKey.hooks); + } + + function _checkAllowedHook(Currency currency, IHooks hooks) internal view returns (bool) { + address permissionedToken = _verifiedPermissionedTokenOf(currency); + if (permissionedToken == address(0)) return true; + return isAllowedHooks[currency][hooks]; + } + + /// @dev When paying to settle, if the currency is a permissioned token, wrap the token and transfer it to the pool manager. + function _pay(Currency currency, address payer, uint256 amount) internal virtual override { + address permissionedToken = _verifiedPermissionedTokenOf(currency); + if (permissionedToken == address(0)) { + // token is not a permissioned token, use the default implementation + super._pay(currency, payer, amount); + return; + } + // token is permissioned, wrap the token and transfer it to the pool manager + IPermissionsAdapter permissionsAdapter = IPermissionsAdapter(Currency.unwrap(currency)); + if (payer == address(this)) { + // Check liquidity permission for the actual user + if (!permissionsAdapter.isAllowed(msgSender(), PermissionFlags.LIQUIDITY_ALLOWED)) { + revert Unauthorized(); + } + Currency.wrap(permissionedToken).transfer(address(permissionsAdapter), amount); + permissionsAdapter.wrapToPoolManager(amount); + } else { + // Check liquidity permission for the actual user + if (!permissionsAdapter.isAllowed(msgSender(), PermissionFlags.LIQUIDITY_ALLOWED)) { + revert Unauthorized(); + } + // token is a permissioned token, wrap the token + permit2.transferFrom(payer, address(permissionsAdapter), uint160(amount), permissionedToken); + permissionsAdapter.wrapToPoolManager(amount); + } + } + + function _verifiedPermissionedTokenOf(Currency currency) internal view returns (address) { + return PERMISSIONS_ADAPTER_FACTORY.verifiedPermissionsAdapterOf(Currency.unwrap(currency)); + } + + /// @notice Calculates the amount for a settle action + function _mapSettleAmount(uint256 amount, Currency currency) internal view override returns (uint256) { + address permissionedToken = _verifiedPermissionedTokenOf(currency); + if (permissionedToken == address(0) || amount != ActionConstants.CONTRACT_BALANCE) { + return super._mapSettleAmount(amount, currency); + } + return Currency.wrap(permissionedToken).balanceOfSelf(); + } + + function _getOwner(Currency currency) internal view returns (address) { + address permissionsAdapter = Currency.unwrap(currency); + address permissionedToken = _verifiedPermissionedTokenOf(currency); + if (permissionedToken == address(0)) return address(0); + return IPermissionsAdapter(permissionsAdapter).owner(); + } +} diff --git a/src/hooks/permissionedPools/PermissionedV4Router.sol b/src/hooks/permissionedPools/PermissionedV4Router.sol new file mode 100644 index 000000000..0bb47e1ae --- /dev/null +++ b/src/hooks/permissionedPools/PermissionedV4Router.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ActionConstants} from "../../libraries/ActionConstants.sol"; +import {V4Router, IPoolManager, Currency} from "../../V4Router.sol"; +import {IPermissionsAdapter} from "./interfaces/IPermissionsAdapter.sol"; +import {IPermissionsAdapterFactory} from "./interfaces/IPermissionsAdapterFactory.sol"; +import {PermissionFlags} from "./libraries/PermissionFlags.sol"; + +/// @title Abstract base for routers that support permissioned V4 pools +/// @notice Provides _pay and _mapSettleAmount overrides for wrapping/unwrapping permissioned tokens. +/// Concrete routers (e.g., UniversalRouter's V4SwapRouter) inherit this contract. +abstract contract PermissionedV4Router is V4Router { + IPermissionsAdapterFactory public immutable PERMISSIONS_ADAPTER_FACTORY; + + error Unauthorized(); + + constructor(IPoolManager poolManager_, IPermissionsAdapterFactory permissionsAdapterFactory) + V4Router(poolManager_) + { + PERMISSIONS_ADAPTER_FACTORY = permissionsAdapterFactory; + } + + function _pay(Currency currency, address payer, uint256 amount) internal virtual override { + address permissionedToken = address(PERMISSIONS_ADAPTER_FACTORY) == address(0) + ? address(0) + : PERMISSIONS_ADAPTER_FACTORY.verifiedPermissionsAdapterOf(Currency.unwrap(currency)); + if (permissionedToken == address(0)) { + _payStandard(currency, payer, amount); + return; + } + // token is permissioned, wrap the token and transfer it to the pool manager + IPermissionsAdapter permissionsAdapter = IPermissionsAdapter(Currency.unwrap(currency)); + if (!permissionsAdapter.isAllowed(msgSender(), PermissionFlags.SWAP_ALLOWED)) { + revert Unauthorized(); + } + if (payer == address(this)) { + Currency.wrap(permissionedToken).transfer(address(permissionsAdapter), amount); + permissionsAdapter.wrapToPoolManager(amount); + } else { + _payPermissionedFromPayer(payer, permissionsAdapter, permissionedToken, amount); + } + } + + /// @notice Hook for concrete routers to implement standard (non-permissioned) payment + function _payStandard(Currency currency, address payer, uint256 amount) internal virtual; + + /// @notice Hook for concrete routers to implement payer-to-adapter transfer (e.g., via Permit2) + function _payPermissionedFromPayer( + address payer, + IPermissionsAdapter permissionsAdapter, + address permissionedToken, + uint256 amount + ) internal virtual; + + /// @notice Calculates the amount for a settle action + function _mapSettleAmount(uint256 amount, Currency currency) internal view virtual override returns (uint256) { + address permissionedToken = address(PERMISSIONS_ADAPTER_FACTORY) == address(0) + ? address(0) + : PERMISSIONS_ADAPTER_FACTORY.verifiedPermissionsAdapterOf(Currency.unwrap(currency)); + // use the default implementation unless the currency is a permissioned token with a balance on the router + if (permissionedToken == address(0) || amount != ActionConstants.CONTRACT_BALANCE) { + return super._mapSettleAmount(amount, currency); + } + return Currency.wrap(permissionedToken).balanceOfSelf(); + } +} diff --git a/src/hooks/permissionedPools/PermissionsAdapter.sol b/src/hooks/permissionedPools/PermissionsAdapter.sol new file mode 100644 index 000000000..dc4facdb8 --- /dev/null +++ b/src/hooks/permissionedPools/PermissionsAdapter.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IPermissionsAdapter} from "./interfaces/IPermissionsAdapter.sol"; +import {IAllowlistChecker} from "./interfaces/IAllowlistChecker.sol"; +import {PermissionFlag} from "./libraries/PermissionFlags.sol"; + +contract PermissionsAdapter is ERC20, Ownable2Step, IPermissionsAdapter { + /// @inheritdoc IPermissionsAdapter + address public immutable POOL_MANAGER; + + /// @inheritdoc IPermissionsAdapter + IERC20 public immutable PERMISSIONED_TOKEN; + + /// @inheritdoc IPermissionsAdapter + IAllowlistChecker public allowListChecker; + + /// @inheritdoc IPermissionsAdapter + bool public swappingEnabled; + + /// @inheritdoc IPermissionsAdapter + mapping(address wrapper => bool) public allowedWrappers; + + constructor( + IERC20 permissionedToken, + address poolManager, + address initialOwner, + IAllowlistChecker allowListChecker_ + ) ERC20(_getName(permissionedToken), _getSymbol(permissionedToken)) Ownable(initialOwner) { + PERMISSIONED_TOKEN = permissionedToken; + POOL_MANAGER = poolManager; + _updateAllowListChecker(allowListChecker_); + } + + /// @inheritdoc IPermissionsAdapter + function wrapToPoolManager(uint256 amount) external { + if (!allowedWrappers[msg.sender]) revert UnauthorizedWrapper(msg.sender); + uint256 availableBalance = PERMISSIONED_TOKEN.balanceOf(address(this)) - totalSupply(); + if (amount > availableBalance) revert InsufficientBalance(amount, availableBalance); + _mint(POOL_MANAGER, amount); + } + + /// @inheritdoc IPermissionsAdapter + function updateAllowListChecker(IAllowlistChecker newAllowListChecker) external onlyOwner { + _updateAllowListChecker(newAllowListChecker); + } + + /// @inheritdoc IPermissionsAdapter + function updateAllowedWrapper(address wrapper, bool allowed) external onlyOwner { + _updateAllowedWrapper(wrapper, allowed); + } + + /// @inheritdoc IPermissionsAdapter + function updateSwappingEnabled(bool enabled) external onlyOwner { + _updateSwappingEnabled(enabled); + } + + /// @inheritdoc IPermissionsAdapter + function isAllowed(address account, PermissionFlag permission) public view returns (bool) { + return ((allowListChecker.checkAllowlist(account)) & (permission)) == (permission); + } + + function _updateAllowListChecker(IAllowlistChecker newAllowListChecker) internal { + if (!newAllowListChecker.supportsInterface(type(IAllowlistChecker).interfaceId)) { + revert InvalidAllowListChecker(newAllowListChecker); + } + allowListChecker = newAllowListChecker; + emit AllowListCheckerUpdated(newAllowListChecker); + } + + function _updateAllowedWrapper(address wrapper, bool allowed) internal { + allowedWrappers[wrapper] = allowed; + emit AllowedWrapperUpdated(wrapper, allowed); + } + + function _updateSwappingEnabled(bool enabled) internal { + swappingEnabled = enabled; + emit SwappingEnabledUpdated(enabled); + } + + /// @dev Overrides the ERC20._update function to add the following checks and logic: + /// - Before `settle` is called on the pool manager, the permissioned token is deposited and the permissions adapter is minted to the pool manager + /// - When `take` is called on the pool manager, the permissioned token is automatically released when the pool manager transfers the permissions adapter to the recipient + /// - Enforces that the pool manager is the only holder of the permissions adapter + function _update(address from, address to, uint256 amount) internal override { + if (to == address(0)) { + // prevents infinite loop when burning + super._update(from, to, amount); + return; + } + if (from == address(0)) { + assert(to == POOL_MANAGER); + // permissioned token is being deposited + super._update(from, to, amount); + return; + } else if (from != POOL_MANAGER) { + // if the pool manager is the sender, the permissioned token is automatically released, skip the checks + revert InvalidTransfer(from, to); + } + super._update(from, to, amount); + if (from == POOL_MANAGER) { + _unwrap(to, amount); + } + // the pool manager must always be the only holder of the permissions adapter + assert(balanceOf(POOL_MANAGER) == totalSupply()); + } + + function _unwrap(address account, uint256 amount) internal { + _burn(account, amount); + PERMISSIONED_TOKEN.transfer(account, amount); + } + + function _getName(IERC20 permissionedToken) private view returns (string memory) { + return string.concat("Uniswap v4 ", ERC20(address(permissionedToken)).name()); + } + + function _getSymbol(IERC20 permissionedToken) private view returns (string memory) { + return string.concat("v4", ERC20(address(permissionedToken)).symbol()); + } + + function decimals() public view override returns (uint8) { + return ERC20(address(PERMISSIONED_TOKEN)).decimals(); + } + + function owner() public view override(Ownable, IPermissionsAdapter) returns (address) { + return super.owner(); + } +} diff --git a/src/hooks/permissionedPools/PermissionsAdapterFactory.sol b/src/hooks/permissionedPools/PermissionsAdapterFactory.sol new file mode 100644 index 000000000..d407d617d --- /dev/null +++ b/src/hooks/permissionedPools/PermissionsAdapterFactory.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IPermissionsAdapter, IERC20} from "./interfaces/IPermissionsAdapter.sol"; +import {IAllowlistChecker} from "./interfaces/IAllowlistChecker.sol"; +import {PermissionsAdapter} from "./PermissionsAdapter.sol"; +import {IPermissionsAdapterFactory} from "./interfaces/IPermissionsAdapterFactory.sol"; + +contract PermissionsAdapterFactory is IPermissionsAdapterFactory { + address public immutable POOL_MANAGER; + + /// @inheritdoc IPermissionsAdapterFactory + mapping(address permissionsAdapter => address permissionedToken) public permissionsAdapterOf; + /// @inheritdoc IPermissionsAdapterFactory + mapping(address permissionsAdapter => address permissionedToken) public verifiedPermissionsAdapterOf; + + constructor(address poolManager) { + POOL_MANAGER = poolManager; + } + + /// @inheritdoc IPermissionsAdapterFactory + function createPermissionsAdapter( + IERC20 permissionedToken, + address initialOwner, + IAllowlistChecker allowListChecker + ) external returns (address permissionsAdapter) { + permissionsAdapter = address( + new PermissionsAdapter(permissionedToken, POOL_MANAGER, initialOwner, allowListChecker) + ); + permissionsAdapterOf[permissionsAdapter] = address(permissionedToken); + emit PermissionsAdapterCreated(permissionsAdapter, address(permissionedToken)); + } + + /// @inheritdoc IPermissionsAdapterFactory + function verifyPermissionsAdapter(address permissionsAdapter) external { + IERC20 permissionedToken = IERC20(permissionsAdapterOf[permissionsAdapter]); + if (address(permissionedToken) == address(0)) revert PermissionsAdapterNotFound(permissionsAdapter); + if (verifiedPermissionsAdapterOf[permissionsAdapter] != address(0)) { + revert PemissionsAdapterAlreadyVerified(permissionsAdapter); + } + // this requires that the verifier has some control or ownership of the permissioned token + if (permissionedToken.balanceOf(permissionsAdapter) == 0) { + revert PemissionsAdapterNotVerified(permissionsAdapter); + } + verifiedPermissionsAdapterOf[permissionsAdapter] = address(permissionedToken); + emit PemissionsAdapterVerified(permissionsAdapter, address(permissionedToken)); + } +} diff --git a/src/hooks/permissionedPools/interfaces/IAllowlistChecker.sol b/src/hooks/permissionedPools/interfaces/IAllowlistChecker.sol new file mode 100644 index 000000000..6cde1833b --- /dev/null +++ b/src/hooks/permissionedPools/interfaces/IAllowlistChecker.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {PermissionFlag} from "../libraries/PermissionFlags.sol"; + +interface IAllowlistChecker is IERC165 { + function checkAllowlist(address account) external view returns (PermissionFlag); +} diff --git a/src/hooks/permissionedPools/interfaces/IPermissionsAdapter.sol b/src/hooks/permissionedPools/interfaces/IPermissionsAdapter.sol new file mode 100644 index 000000000..2a057c135 --- /dev/null +++ b/src/hooks/permissionedPools/interfaces/IPermissionsAdapter.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IAllowlistChecker, PermissionFlag} from "./IAllowlistChecker.sol"; + +interface IPermissionsAdapter is IERC20 { + /// @notice Emitted when the allow list checker is updated + event AllowListCheckerUpdated(IAllowlistChecker indexed newAllowListChecker); + + /// @notice Emitted when an allowed wrapper is updated + event AllowedWrapperUpdated(address indexed wrapper, bool allowed); + + /// @notice Emitted when the swapping enabled status is updated + event SwappingEnabledUpdated(bool enabled); + + /// @notice Thrown when the allow list checker does not implement the IAllowListChecker interface + error InvalidAllowListChecker(IAllowlistChecker newAllowListChecker); + + /// @notice Thrown when the transfer is not interacting with the pool manager + error InvalidTransfer(address from, address to); + + /// @notice Thrown when the wrapper is not allowed to trigger transfers on the permissions adapter + error UnauthorizedWrapper(address wrapper); + + /// @notice Thrown when there is an insufficient amount of permissioned tokens available to wrap + error InsufficientBalance(uint256 amount, uint256 availableBalance); + + /// @notice Wraps the permissioned token to the pool manager + /// @param amount The amount of permissioned tokens to wrap + /// @dev Only callable by allowed wrappers + /// @dev The `amount` must be sent to this contract before calling this function + function wrapToPoolManager(uint256 amount) external; + + /// @notice Updates the allow list checker + /// @param newAllowListChecker The new allow list checker + /// @dev Only callable by the owner + function updateAllowListChecker(IAllowlistChecker newAllowListChecker) external; + + /// @notice Updates the allowed wrapper that can wrap the permissioned token + /// @param wrapper The wrapper to update + /// @param allowed Whether the wrapper is allowed + /// @dev Only callable by the owner + /// @dev To ensure the permissions adapter cannot be wrapped in an ERC6909 token on the PoolManager, the wrapper must only implement `swap` or `modifyLiquidity` functions + function updateAllowedWrapper(address wrapper, bool allowed) external; + + /// @notice Updates the swapping enabled status + /// @param enabled Whether swapping is enabled + /// @dev Only callable by the owner + function updateSwappingEnabled(bool enabled) external; + + /// @notice Returns whether a transfer is allowed + /// @param account The account to check + function isAllowed(address account, PermissionFlag permission) external view returns (bool); + + /// @notice Returns the allow list checker + function allowListChecker() external view returns (IAllowlistChecker); + + /// @notice Returns the allowed wrappers that can wrap the permissioned token + /// @dev e.g., the permissioned pool manager, quoters or the swap router + function allowedWrappers(address wrapper) external view returns (bool); + + /// @notice Returns the swapping enabled status + function swappingEnabled() external view returns (bool); + + /// @notice Returns the Uniswap v4 pool manager + function POOL_MANAGER() external view returns (address); + + /// @notice Returns the permissioned token that is deposited in this contract + function PERMISSIONED_TOKEN() external view returns (IERC20); + + /// @notice Returns the admin of the permissions adapter + function owner() external view returns (address); +} diff --git a/src/hooks/permissionedPools/interfaces/IPermissionsAdapterFactory.sol b/src/hooks/permissionedPools/interfaces/IPermissionsAdapterFactory.sol new file mode 100644 index 000000000..55a72ee32 --- /dev/null +++ b/src/hooks/permissionedPools/interfaces/IPermissionsAdapterFactory.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC20} from "./IPermissionsAdapter.sol"; +import {IAllowlistChecker} from "./IAllowlistChecker.sol"; + +interface IPermissionsAdapterFactory { + /// @notice Emitted when a permissions adapter is created + event PermissionsAdapterCreated(address indexed permissionsAdapter, address indexed permissionedToken); + + /// @notice Emitted when a permissions adapter is verified + event PemissionsAdapterVerified(address indexed permissionsAdapter, address indexed permissionedToken); + + /// @notice Thrown when the permissions adapter does not exist + error PermissionsAdapterNotFound(address permissionsAdapter); + + /// @notice Thrown when the permissions adapter is already verified + error PemissionsAdapterAlreadyVerified(address permissionsAdapter); + + /// @notice Thrown when the permissions adapter is not verified + error PemissionsAdapterNotVerified(address permissionsAdapter); + + /// @notice Creates a new permissions adapter + /// @param permissionedToken The permissioned token to wrap + /// @param initialOwner The initial owner of the permissions adapter + /// @param allowListChecker The allow list checker that will be used to check if transfers are allowed + function createPermissionsAdapter( + IERC20 permissionedToken, + address initialOwner, + IAllowlistChecker allowListChecker + ) external returns (address); + + /// @notice Verifies a permissions adapter + /// @param permissionsAdapter The permissions adapter + /// @dev This function verifies that the permissions adapter has a balance of the permissioned token. This means that the permissions adapter is on the allow list of the permissioned token and can be used to wrap and unwrap the permissioned token. + function verifyPermissionsAdapter(address permissionsAdapter) external; + + /// @notice Returns the permissioned token of a permissions adapter + /// @param permissionsAdapter The permissions adapter + /// @return permissionedToken The permissioned token + function permissionsAdapterOf(address permissionsAdapter) external view returns (address permissionedToken); + + /// @notice Returns the verified permissioned token of a permissions adapter + /// @param permissionsAdapter The permissions adapter + /// @return permissionedToken The verified permissioned token + /// @dev A reverse lookup of the permissioned token is required, otherwise anyone could create a permissions adapter for a non-permissioned token + function verifiedPermissionsAdapterOf(address permissionsAdapter) external view returns (address permissionedToken); + + /// @notice Returns the v4 pool manager + /// @return poolManager The v4 pool manager + function POOL_MANAGER() external view returns (address poolManager); +} diff --git a/src/hooks/permissionedPools/libraries/PermissionFlags.sol b/src/hooks/permissionedPools/libraries/PermissionFlags.sol new file mode 100644 index 000000000..3c95decb9 --- /dev/null +++ b/src/hooks/permissionedPools/libraries/PermissionFlags.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +type PermissionFlag is bytes2; + +using {or as |} for PermissionFlag global; +using {and as &} for PermissionFlag global; +using {eq as ==} for PermissionFlag global; + +function or(PermissionFlag a, PermissionFlag b) pure returns (PermissionFlag) { + return PermissionFlag.wrap(PermissionFlag.unwrap(a) | PermissionFlag.unwrap(b)); +} + +function and(PermissionFlag a, PermissionFlag b) pure returns (PermissionFlag) { + return PermissionFlag.wrap(PermissionFlag.unwrap(a) & PermissionFlag.unwrap(b)); +} + +function eq(PermissionFlag a, PermissionFlag b) pure returns (bool) { + return PermissionFlag.unwrap(a) == PermissionFlag.unwrap(b); +} + +library PermissionFlags { + PermissionFlag constant NONE = PermissionFlag.wrap(0x0000); + PermissionFlag constant SWAP_ALLOWED = PermissionFlag.wrap(0x0001); + PermissionFlag constant LIQUIDITY_ALLOWED = PermissionFlag.wrap(0x0002); + PermissionFlag constant ALL_ALLOWED = PermissionFlag.wrap(0xFFFF); + + function hasFlag(PermissionFlag permissions, PermissionFlag flag) internal pure returns (bool) { + return PermissionFlag.unwrap(and(permissions, flag)) != 0; + } +} diff --git a/src/utils/HookMiner.sol b/src/utils/HookMiner.sol new file mode 100644 index 000000000..3c1f487ec --- /dev/null +++ b/src/utils/HookMiner.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; + +/// @title HookMiner +/// @notice a minimal library for mining hook addresses +library HookMiner { + // mask to slice out the bottom 14 bit of the address + uint160 constant FLAG_MASK = Hooks.ALL_HOOK_MASK; // 0000 ... 0000 0011 1111 1111 1111 + + // Maximum number of iterations to find a salt, avoid infinite loops or MemoryOOG + // (arbitrarily set) + uint256 constant MAX_LOOP = 160_444; + + /// @notice Find a salt that produces a hook address with the desired `flags` + /// @param deployer The address that will deploy the hook. In `forge test`, this will be the test contract `address(this)` or the pranking address + /// In `forge script`, this should be `0x4e59b44847b379578588920cA78FbF26c0B4956C` (CREATE2 Deployer Proxy) + /// @param flags The desired flags for the hook address. Example `uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | ...)` + /// @param creationCode The creation code of a hook contract. Example: `type(Counter).creationCode` + /// @param constructorArgs The encoded constructor arguments of a hook contract. Example: `abi.encode(address(manager))` + /// @return (hookAddress, salt) The hook deploys to `hookAddress` when using `salt` with the syntax: `new Hook{salt: salt}()` + function find(address deployer, uint160 flags, bytes memory creationCode, bytes memory constructorArgs) + internal + view + returns (address, bytes32) + { + flags = flags & FLAG_MASK; // mask for only the bottom 14 bits + bytes memory creationCodeWithArgs = abi.encodePacked(creationCode, constructorArgs); + + address hookAddress; + for (uint256 salt; salt < MAX_LOOP; salt++) { + hookAddress = computeAddress(deployer, salt, creationCodeWithArgs); + + // if the hook's bottom 14 bits match the desired flags AND the address does not have bytecode, we found a match + if (uint160(hookAddress) & FLAG_MASK == flags && hookAddress.code.length == 0) { + return (hookAddress, bytes32(salt)); + } + } + revert("HookMiner: could not find salt"); + } + + /// @notice Precompute a contract address deployed via CREATE2 + /// @param deployer The address that will deploy the hook. In `forge test`, this will be the test contract `address(this)` or the pranking address + /// In `forge script`, this should be `0x4e59b44847b379578588920cA78FbF26c0B4956C` (CREATE2 Deployer Proxy) + /// @param salt The salt used to deploy the hook + /// @param creationCodeWithArgs The creation code of a hook contract, with encoded constructor arguments appended. Example: `abi.encodePacked(type(Counter).creationCode, abi.encode(constructorArg1, constructorArg2))` + function computeAddress(address deployer, uint256 salt, bytes memory creationCodeWithArgs) + internal + pure + returns (address hookAddress) + { + return address( + uint160(uint256(keccak256(abi.encodePacked(bytes1(0xFF), deployer, salt, keccak256(creationCodeWithArgs))))) + ); + } +} diff --git a/src/utils/HookMinerCreate3.sol b/src/utils/HookMinerCreate3.sol new file mode 100644 index 000000000..4ae914688 --- /dev/null +++ b/src/utils/HookMinerCreate3.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {CREATE3} from "solmate/src/utils/CREATE3.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; + +/// @title HookMinerCreate3 +/// @notice A minimal library for mining hook addresses using CREATE3 +library HookMinerCreate3 { + // mask to slice out the bottom 14 bit of the address + uint160 constant FLAG_MASK = Hooks.ALL_HOOK_MASK; // 0000 ... 0000 0011 1111 1111 1111 + + // Maximum number of iterations to find a salt, avoid infinite loops or MemoryOOG + // (arbitrarily set) + uint256 constant MAX_LOOP = 160_444; + + /// @notice Find a salt that produces a hook address with the desired `flags` using CREATE3 + /// @param deployer The address that will deploy the hook. In `forge test`, this will be the test contract `address(this)` or the pranking address + /// In `forge script`, this should be `0x4e59b44847b379578588920cA78FbF26c0B4956C` (CREATE2 Deployer Proxy) + /// @param flags The desired flags for the hook address. Example `uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | ...)` + /// @param creationCode The creation code of a hook contract. Example: `type(Counter).creationCode` + /// @param constructorArgs The encoded constructor arguments of a hook contract. Example: `abi.encode(address(manager))` + /// @return (hookAddress, salt) The hook deploys to `hookAddress` when using `salt` with CREATE3 + function find(address deployer, uint160 flags, bytes memory creationCode, bytes memory constructorArgs) + internal + view + returns (address, bytes32) + { + flags = flags & FLAG_MASK; // mask for only the bottom 14 bits + bytes memory creationCodeWithArgs = abi.encodePacked(creationCode, constructorArgs); + + address hookAddress; + for (uint256 salt; salt < MAX_LOOP; salt++) { + hookAddress = computeAddress(deployer, salt, creationCodeWithArgs); + + // if the hook's bottom 14 bits match the desired flags AND the address does not have bytecode, we found a match + if (uint160(hookAddress) & FLAG_MASK == flags && hookAddress.code.length == 0) { + return (hookAddress, bytes32(salt)); + } + } + revert("HookMinerCreate3: could not find salt"); + } + + /// @notice Precompute a contract address deployed via CREATE3 + /// @param deployer The address that will deploy the hook. In `forge test`, this will be the test contract `address(this)` or the pranking address + /// In `forge script`, this should be `0x4e59b44847b379578588920cA78FbF26c0B4956C` (CREATE2 Deployer Proxy) + /// @param salt The salt used to deploy the hook + function computeAddress(address deployer, uint256 salt, bytes memory) internal pure returns (address hookAddress) { + bytes32 saltBytes = bytes32(salt); + return CREATE3.getDeployed(saltBytes, deployer); + } + + /// @notice Find a salt that produces a hook address with the desired `flags` using CREATE3 with a custom salt prefix + /// @param deployer The address that will deploy the hook + /// @param flags The desired flags for the hook address + /// @param creationCode The creation code of a hook contract + /// @param constructorArgs The encoded constructor arguments of a hook contract + /// @param saltPrefix A prefix to use for the salt (e.g., "permissioned-router-") + /// @return (hookAddress, salt) The hook deploys to `hookAddress` when using `salt` with CREATE3 + function findWithPrefix( + address deployer, + uint160 flags, + bytes memory creationCode, + bytes memory constructorArgs, + string memory saltPrefix + ) internal view returns (address, bytes32) { + flags = flags & FLAG_MASK; // mask for only the bottom 14 bits + bytes memory creationCodeWithArgs = abi.encodePacked(creationCode, constructorArgs); + + address hookAddress; + for (uint256 i; i < MAX_LOOP; i++) { + bytes32 salt = keccak256(abi.encodePacked(saltPrefix, i)); + hookAddress = computeAddress(deployer, uint256(salt), creationCodeWithArgs); + + // if the hook's bottom 14 bits match the desired flags AND the address does not have bytecode, we found a match + if (uint160(hookAddress) & FLAG_MASK == flags && hookAddress.code.length == 0) { + return (hookAddress, salt); + } + } + revert("HookMinerCreate3: could not find salt with prefix"); + } +} diff --git a/test/hooks/permissionedPools/PermissionedPoolsBase.sol b/test/hooks/permissionedPools/PermissionedPoolsBase.sol new file mode 100644 index 000000000..1bf7987ab --- /dev/null +++ b/test/hooks/permissionedPools/PermissionedPoolsBase.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {BaseAllowlistChecker} from "../../../src/hooks/permissionedPools/BaseAllowListChecker.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {PermissionFlag} from "../../../src/hooks/permissionedPools/libraries/PermissionFlags.sol"; + +contract MockToken is ERC20 { + constructor() ERC20("MockToken", "MT") {} + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } + + function burn(address from, uint256 amount) public { + _burn(from, amount); + } + + function decimals() public pure override returns (uint8) { + return 6; + } +} + +contract MockPermissionedToken is MockToken { + // this mapping is the allow list for permissioned pools + mapping(address account => PermissionFlag permissions) public accountPermissions; + // this mapping represents if the account is allowed to interact with the token + mapping(address account => bool isAllowed) public isAllowed; + + error Unauthorized(); + + function setAllowlist(address account, PermissionFlag permissions) public { + accountPermissions[account] = permissions; + // allowed to transfer the token + isAllowed[account] = true; + } + + function setTokenAllowlist(address account, bool allowed) public { + isAllowed[account] = allowed; + } + + function _update(address from, address to, uint256 amount) internal override { + if (!isAllowed[to]) revert Unauthorized(); + super._update(from, to, amount); + } +} + +contract MockAllowlistChecker is BaseAllowlistChecker { + MockPermissionedToken public token; + + constructor(MockPermissionedToken token_) { + token = token_; + } + + function checkAllowlist(address account) public view override returns (PermissionFlag) { + return token.accountPermissions(account); + } +} + +contract PermissionedPoolsBase is Test { + MockPermissionedToken public permissionedToken; + MockAllowlistChecker public allowlistChecker; + + function setUp() public virtual { + permissionedToken = new MockPermissionedToken(); + allowlistChecker = new MockAllowlistChecker(permissionedToken); + } +} diff --git a/test/hooks/permissionedPools/PermissionedPositionManager.t.sol b/test/hooks/permissionedPools/PermissionedPositionManager.t.sol new file mode 100644 index 000000000..91b436901 --- /dev/null +++ b/test/hooks/permissionedPools/PermissionedPositionManager.t.sol @@ -0,0 +1,1463 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "forge-std/Test.sol"; +import {IERC721} from "forge-std/interfaces/IERC721.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Currency} 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 {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FixedPointMathLib} from "solmate/src/utils/FixedPointMathLib.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; +import {CustomRevert} from "@uniswap/v4-core/src/libraries/CustomRevert.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import {IPositionManager} from "../../../src/interfaces/IPositionManager.sol"; +import {Actions} from "../../../src/libraries/Actions.sol"; +import {DeltaResolver} from "../../../src/base/DeltaResolver.sol"; +import {PositionConfig} from "test/shared/PositionConfig.sol"; +import {SlippageCheck} from "../../../src/libraries/SlippageCheck.sol"; +import {BaseActionsRouter} from "../../../src/base/BaseActionsRouter.sol"; +import {ActionConstants} from "../../../src/libraries/ActionConstants.sol"; +import {LiquidityFuzzers} from "../../shared/fuzz/LiquidityFuzzers.sol"; +import {Planner, Plan} from "../../shared/Planner.sol"; +import {PermissionedPosmTestSetup, BalanceInfo} from "./shared/PermissionedPosmTestSetup.sol"; +import {ReentrantToken} from "../../mocks/ReentrantToken.sol"; +import {ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {MockAllowlistChecker, MockPermissionedToken} from "./PermissionedPoolsBase.sol"; +import {PermissionsAdapter, IERC20} from "../../../src/hooks/permissionedPools/PermissionsAdapter.sol"; +import {PermissionFlags, PermissionFlag} from "../../../src/hooks/permissionedPools/libraries/PermissionFlags.sol"; + +contract PermissionedPositionManagerTest is Test, PermissionedPosmTestSetup, LiquidityFuzzers { + using FixedPointMathLib for uint256; + using StateLibrary for IPoolManager; + + // To allow testing without importing PermissionedHooks + error Unauthorized(); + error InvalidHook(); + error HookCallFailed(); + + PoolId public poolId; + PoolId public poolIdFake; + + // Permissioned components + MockAllowlistChecker public mockAllowListChecker; + PermissionsAdapter public permissionsAdapter0; + PermissionsAdapter public permissionsAdapter2; + MockERC20 public originalToken0; + MockERC20 public originalToken2; + IPositionManager public secondaryPosm; + IPositionManager public tertiaryPosm; + + // permissioned / normal + PoolKey public key0; + // normal / permissioned + PoolKey public key1; + // permissioned / permissioned + PoolKey public key2; + // normal / normal + PoolKey public key3; + + PoolKey public keyFake0; + PoolKey public keyFake1; + PoolKey public keyFake2; + + PoolKey public insecureKey; + + // Test Users + address public alice = makeAddr("ALICE"); + address public unauthorizedUser = makeAddr("UNAUTHORIZED"); + + function setUp() public { + permit2 = IAllowanceTransfer(deployPermit2()); + + deployFreshManagerAndRoutersPermissioned(address(permit2), address(_WETH9)); + (currency0, currency1) = deployMintAndApprove2Currencies(true, false); + currency2 = deployMintAndApproveCurrency(true); + + setUpPosms(); + setupPermissionedComponents(); + setupPool(); + + // set up approvals for alice + seedBalance(alice); + approvePosmFor(alice); + permissionsAdapter0.updateSwappingEnabled(true); + permissionsAdapter2.updateSwappingEnabled(true); + } + + function setUpPosms() internal { + deployAndApprovePosm(manager, address(permissionsAdapterFactory), keccak256("permissionedPosm")); + + // alternate position manager using different hooks + secondaryPosm = + deployAndApprovePosmOnly(manager, address(permissionsAdapterFactory), keccak256("secondaryPosm")); + + // alternate position manager using the same hooks + tertiaryPosm = deployAndApprovePosmOnly(manager, address(permissionsAdapterFactory), keccak256("tertiaryPosm")); + } + + function setupPool() internal { + (key0, poolId) = + initPool(Currency.wrap(address(permissionsAdapter0)), currency1, permissionedHooks, 3000, SQRT_PRICE_1_1); + (key1, poolId) = + initPool(currency1, Currency.wrap(address(permissionsAdapter2)), permissionedHooks, 3000, SQRT_PRICE_1_1); + (key2, poolId) = initPool( + Currency.wrap(address(permissionsAdapter0)), + Currency.wrap(address(permissionsAdapter2)), + permissionedHooks, + 3000, + SQRT_PRICE_1_1 + ); + + (keyFake0, poolId) = initPool( + Currency.wrap(address(permissionsAdapter0)), currency1, secondaryPermissionedHooks, 3000, SQRT_PRICE_1_1 + ); + (keyFake1, poolId) = initPool( + currency1, Currency.wrap(address(permissionsAdapter2)), secondaryPermissionedHooks, 3000, SQRT_PRICE_1_1 + ); + (keyFake2, poolId) = initPool( + Currency.wrap(address(permissionsAdapter0)), + Currency.wrap(address(permissionsAdapter2)), + secondaryPermissionedHooks, + 3000, + SQRT_PRICE_1_1 + ); + (insecureKey, poolId) = initPool( + Currency.wrap(address(permissionsAdapter0)), + Currency.wrap(address(permissionsAdapter2)), + insecureHooks, + 3000, + SQRT_PRICE_1_1 + ); + } + + function setupPermissionedComponents() internal { + mockAllowListChecker = new MockAllowlistChecker(MockPermissionedToken(Currency.unwrap(currency0))); + setUpCurrencyZero(); + setUpCurrencyTwo(); + } + + function setUpCurrencyZero() internal { + setUpAllowlist(currency0); + originalToken0 = MockERC20(Currency.unwrap(currency0)); + // ensure expected ordering + while (true) { + permissionsAdapter0 = PermissionsAdapter( + permissionsAdapterFactory.createPermissionsAdapter( + IERC20(address(originalToken0)), address(this), mockAllowListChecker + ) + ); + if (address(permissionsAdapter0) < Currency.unwrap(currency1)) { + break; + } + } + setUpPemissionsAdapter(permissionsAdapter0, currency0); + } + + function setUpCurrencyTwo() internal { + setUpAllowlist(currency2); + originalToken2 = MockERC20(Currency.unwrap(currency2)); + + // ensure expected ordering + while (true) { + permissionsAdapter2 = PermissionsAdapter( + permissionsAdapterFactory.createPermissionsAdapter( + IERC20(address(originalToken2)), address(this), mockAllowListChecker + ) + ); + if (Currency.unwrap(currency1) < address(permissionsAdapter2)) { + break; + } + } + setUpPemissionsAdapter(permissionsAdapter2, currency2); + } + + function setUpAllowlist(Currency currency) internal { + MockPermissionedToken(Currency.unwrap(currency)).setAllowlist(address(this), PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency)).setAllowlist(alice, PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency)).setAllowlist(address(lpm), PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency)) + .setAllowlist(address(secondaryPosm), PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency)) + .setAllowlist(address(permissionsAdapterFactory), PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency)).setAllowlist(address(lpm), PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency)).setAllowlist(address(manager), PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency)) + .setAllowlist(address(permissionedSwapRouter), PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency2)) + .setAllowlist(address(permissionedHooks), PermissionFlags.ALL_ALLOWED); + } + + function setUpPemissionsAdapter(PermissionsAdapter permissionsAdapter, Currency currency) internal { + adapterToPermissioned[Currency.wrap(address(permissionsAdapter))] = currency; + + MockPermissionedToken(Currency.unwrap(currency)).mint(address(this), 1000 ether); + MockPermissionedToken(Currency.unwrap(currency)) + .setAllowlist(address(permissionsAdapter), PermissionFlags.ALL_ALLOWED); + + // permissions adapter contract must have a non-zero balance of the permissioned token + currency.transfer(address(permissionsAdapter), 1); + + permissionsAdapter.updateAllowedWrapper(address(manager), true); + permissionsAdapter.updateAllowedWrapper(address(lpm), true); + permissionsAdapter.updateAllowedWrapper(address(secondaryPosm), true); + permissionsAdapter.updateAllowedWrapper(address(permissionedSwapRouter), true); + + permissionsAdapterFactory.verifyPermissionsAdapter(address(permissionsAdapter)); + + Currency permissionsAdapterCurrency = Currency.wrap(address(permissionsAdapter)); + + setAllowedHooks(lpm, permissionsAdapterCurrency, permissionedHooks); + setAllowedHooks(tertiaryPosm, permissionsAdapterCurrency, permissionedHooks); + + setAllowedHooks(lpm, permissionsAdapterCurrency, secondaryPermissionedHooks); + setAllowedHooks(secondaryPosm, permissionsAdapterCurrency, secondaryPermissionedHooks); + setAllowedHooks(tertiaryPosm, permissionsAdapterCurrency, secondaryPermissionedHooks); + + setAllowedHooks(lpm, permissionsAdapterCurrency, insecureHooks); + } + + function setAllowedHooks(IPositionManager posm, Currency currency, IHooks permissionedHooks_) internal { + // setAllowedHook selector + bytes4 selector = 0xb5cdc484; + bytes memory data = abi.encodeWithSelector(selector, currency, permissionedHooks_, true); + (bool success,) = address(posm).call(data); + require(success, "Failed to set hooks"); + } + + function test_modifyLiquidities_reverts_deadlinePassed() public { + _test_modifyLiquidities_reverts_deadlinePassed(key0); + _test_modifyLiquidities_reverts_deadlinePassed(key1); + _test_modifyLiquidities_reverts_deadlinePassed(key2); + } + + function _test_modifyLiquidities_reverts_deadlinePassed(PoolKey memory key) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: 0, tickUpper: 60}); + bytes memory calls = getMintEncoded(config, 1e18, ActionConstants.MSG_SENDER, ""); + + uint256 deadline = vm.getBlockTimestamp() - 1; + + vm.expectRevert(abi.encodeWithSelector(IPositionManager.DeadlinePassed.selector, deadline)); + lpm.modifyLiquidities(calls, deadline); + } + + function test_modifyLiquidities_reverts_mismatchedLengths() public { + Plan memory planner = Planner.init(); + planner.add(Actions.MINT_POSITION, abi.encode("test")); + planner.add(Actions.BURN_POSITION, abi.encode("test")); + + bytes[] memory badParams = new bytes[](1); + + vm.expectRevert(BaseActionsRouter.InputLengthMismatch.selector); + lpm.modifyLiquidities(abi.encode(planner.actions, badParams), block.timestamp + 1); + } + + function test_modifyLiquidities_reverts_reentrancy() public { + PoolKey memory key; + + // Create a reentrant token and initialize the pool with a verified adapter so beforeInitialize passes + Currency reentrantToken = Currency.wrap(address(new ReentrantToken(lpm))); + Currency adapterCurrency = Currency.wrap(address(permissionsAdapter0)); + (Currency c0, Currency c1) = (Currency.unwrap(reentrantToken) < Currency.unwrap(adapterCurrency)) + ? (reentrantToken, adapterCurrency) + : (adapterCurrency, reentrantToken); + + // Set up approvals for the reentrant token + approvePosmCurrency(reentrantToken); + (key, poolId) = initPool(c0, c1, permissionedHooks, 3000, SQRT_PRICE_1_1); + + // Try to add liquidity at that range, but the token reenters posm + PositionConfig memory config = + PositionConfig({poolKey: key, tickLower: -int24(key.tickSpacing), tickUpper: int24(key.tickSpacing)}); + + bytes memory calls = getMintEncoded(config, 1e18, ActionConstants.MSG_SENDER, ""); + + // Permit2.transferFrom does not bubble the ContractLocked error and instead reverts with its own error + vm.expectRevert("TRANSFER_FROM_FAILED"); + lpm.modifyLiquidities(calls, block.timestamp + 1); + } + + function test_fuzz_mint_withLiquidityDelta(ModifyLiquidityParams memory params, uint160 sqrtPriceX96) public { + bound(sqrtPriceX96, MIN_PRICE_LIMIT, MAX_PRICE_LIMIT); + + _test_fuzz_mint_withLiquidityDelta(key0, params, sqrtPriceX96); + _test_fuzz_mint_withLiquidityDelta(key1, params, sqrtPriceX96); + _test_fuzz_mint_withLiquidityDelta(key2, params, sqrtPriceX96); + } + + function _test_fuzz_mint_withLiquidityDelta( + PoolKey memory key, + ModifyLiquidityParams memory params, + uint160 sqrtPriceX96 + ) internal { + params = createFuzzyLiquidityParams(key, params, sqrtPriceX96); + PositionConfig memory config = + PositionConfig({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + // liquidity is a uint + uint256 liquidityToAdd = + params.liquidityDelta < 0 ? uint256(-params.liquidityDelta) : uint256(params.liquidityDelta); + + uint256 balance0Before = getPermissionedCurrency(key.currency0).balanceOfSelf(); + uint256 balance1Before = getPermissionedCurrency(key.currency1).balanceOfSelf(); + uint256 balance0ManagerBefore = key.currency0.balanceOf(address(manager)); + uint256 balance1ManagerBefore = key.currency1.balanceOf(address(manager)); + uint256 tokenId = lpm.nextTokenId(); + + mint(config, liquidityToAdd, ActionConstants.MSG_SENDER, ZERO_BYTES); + + uint256 balance0ManagerAfter = key.currency0.balanceOf(address(manager)); + uint256 balance1ManagerAfter = key.currency1.balanceOf(address(manager)); + uint256 liquidity = lpm.getPositionLiquidity(tokenId); + + assertEq(tokenId, lpm.nextTokenId() - 1); + assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this)); + assertEq(liquidity, uint256(params.liquidityDelta)); + assertEq( + balance0Before - getPermissionedCurrency(key.currency0).balanceOfSelf(), + balance0ManagerAfter - balance0ManagerBefore, + "incorrect amount0" + ); + assertEq( + balance1Before - getPermissionedCurrency(key.currency1).balanceOfSelf(), + balance1ManagerAfter - balance1ManagerBefore, + "incorrect amount1" + ); + } + + function test_mint_exactTokenRatios() public { + _test_mint_exactTokenRatios(key0); + _test_mint_exactTokenRatios(key1); + _test_mint_exactTokenRatios(key2); + } + + function _test_mint_exactTokenRatios(PoolKey memory key) internal { + 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 + ); + + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); + + uint256 balance0Before = getPermissionedCurrency(key.currency0).balanceOfSelf(); + uint256 balance1Before = getPermissionedCurrency(key.currency1).balanceOfSelf(); + uint256 balance0ManagerBefore = key.currency0.balanceOf(address(manager)); + uint256 tokenId = lpm.nextTokenId(); + + mint(config, liquidityToAdd, ActionConstants.MSG_SENDER, ZERO_BYTES); + + uint256 balance0After = getPermissionedCurrency(key.currency0).balanceOfSelf(); + uint256 balance1After = getPermissionedCurrency(key.currency1).balanceOfSelf(); + uint256 balance0ManagerAfter = key.currency0.balanceOf(address(manager)); + assertEq(tokenId, lpm.nextTokenId() - 1); + assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this)); + assertEq(balance0Before - balance0After, balance0ManagerAfter - balance0ManagerBefore); + assertEq(balance1Before - balance1After, amount1Desired); + } + + function test_mint_toRecipient() public { + _test_mint_toRecipient(key0); + _test_mint_toRecipient(key1); + _test_mint_toRecipient(key2); + } + + function _test_mint_toRecipient(PoolKey memory key) internal { + 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 + ); + + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); + + uint256 tokenId = lpm.nextTokenId(); + + BalanceInfo memory balanceInfoBefore = getBalanceInfoSelfAndManager(key); + + // mint to specific recipient, alice, not using the recipient constants + mint(config, liquidityToAdd, alice, ZERO_BYTES); + + BalanceInfo memory balanceInfoAfter = getBalanceInfoSelfAndManager(key); + + assertEq(tokenId, lpm.nextTokenId() - 1); + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + assertEq( + balanceInfoBefore.balance0 - balanceInfoAfter.balance0, + balanceInfoAfter.balance0Manager - balanceInfoBefore.balance0Manager + ); + assertEq( + balanceInfoBefore.balance1 - balanceInfoAfter.balance1, + balanceInfoAfter.balance1Manager - balanceInfoBefore.balance1Manager + ); + assertEq(balanceInfoBefore.balance0 - balanceInfoAfter.balance0, amount0Desired); + assertEq(balanceInfoBefore.balance1 - balanceInfoAfter.balance1, amount1Desired); + } + + function test_fuzz_mint_recipient(ModifyLiquidityParams memory seedParams) public { + _test_fuzz_mint_recipient(key0, seedParams); + _test_fuzz_mint_recipient(key1, seedParams); + _test_fuzz_mint_recipient(key2, seedParams); + } + + function _test_fuzz_mint_recipient(PoolKey memory key, ModifyLiquidityParams memory seedParams) internal { + ModifyLiquidityParams memory params = createFuzzyLiquidityParams(key, seedParams, SQRT_PRICE_1_1); + PositionConfig memory config = + PositionConfig({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + uint256 liquidityToAdd = + params.liquidityDelta < 0 ? uint256(-params.liquidityDelta) : uint256(params.liquidityDelta); + + Currency currency0_ = getPermissionedCurrency(key.currency0); + Currency currency1_ = getPermissionedCurrency(key.currency1); + Currency currency0Adapter = key.currency0; + Currency currency1Adapter = key.currency1; + uint256 tokenId = lpm.nextTokenId(); + uint256 balance0Before = currency0_.balanceOfSelf(); + uint256 balance1Before = currency1_.balanceOfSelf(); + uint256 balance0BeforeAlice = currency0_.balanceOf(alice); + uint256 balance1BeforeAlice = currency1_.balanceOf(alice); + uint256 balance0ManagerBefore = currency0Adapter.balanceOf(address(manager)); + uint256 balance1ManagerBefore = currency1Adapter.balanceOf(address(manager)); + + mint(config, liquidityToAdd, alice, ZERO_BYTES); + + uint256 balance0ManagerAfter = currency0Adapter.balanceOf(address(manager)); + uint256 balance1ManagerAfter = currency1Adapter.balanceOf(address(manager)); + + // alice was not the payer + assertEq(tokenId, lpm.nextTokenId() - 1); + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + assertEq(balance0Before - currency0_.balanceOfSelf(), balance0ManagerAfter - balance0ManagerBefore); + assertEq(balance1Before - currency1_.balanceOfSelf(), balance1ManagerAfter - balance1ManagerBefore); + assertEq(currency0_.balanceOf(alice), balance0BeforeAlice); + assertEq(currency1_.balanceOf(alice), balance1BeforeAlice); + } + + /// @dev clear cannot be used on mint (negative delta) + function test_fuzz_mint_clear_revert(ModifyLiquidityParams memory seedParams) public { + _test_fuzz_mint_clear_revert(key0, seedParams); + _test_fuzz_mint_clear_revert(key1, seedParams); + _test_fuzz_mint_clear_revert(key2, seedParams); + } + + function _test_fuzz_mint_clear_revert(PoolKey memory key, ModifyLiquidityParams memory seedParams) internal { + ModifyLiquidityParams memory params = createFuzzyLiquidityParams(key, seedParams, SQRT_PRICE_1_1); + PositionConfig memory config = + PositionConfig({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + uint256 liquidityToAdd = + params.liquidityDelta < 0 ? uint256(-params.liquidityDelta) : uint256(params.liquidityDelta); + + Plan memory planner = Planner.init(); + planner.add( + Actions.MINT_POSITION, + abi.encode( + config.poolKey, + config.tickLower, + config.tickUpper, + liquidityToAdd, + MAX_SLIPPAGE_INCREASE, + MAX_SLIPPAGE_INCREASE, + address(this), + ZERO_BYTES + ) + ); + planner.add(Actions.CLEAR_OR_TAKE, abi.encode(key.currency0, type(uint256).max)); + planner.add(Actions.CLEAR_OR_TAKE, abi.encode(key.currency1, type(uint256).max)); + bytes memory calls = planner.encode(); + + Currency negativeDeltaCurrency = key.currency0; + // because we're fuzzing the range, single-sided mint with currency1 means currency0Delta = 0 and currency1Delta < 0 + if (config.tickUpper <= 0) { + negativeDeltaCurrency = key.currency1; + } + + vm.expectRevert(abi.encodeWithSelector(DeltaResolver.DeltaNotPositive.selector, (negativeDeltaCurrency))); + lpm.modifyLiquidities(calls, _deadline); + } + + function test_mint_slippage_revertAmount0() public { + _test_mint_slippage_revertAmount0(key0); + _test_mint_slippage_revertAmount0(key1); + _test_mint_slippage_revertAmount0(key2); + } + + function _test_mint_slippage_revertAmount0(PoolKey memory key) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 1e18; + (uint256 amount0,) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(config.tickLower), + TickMath.getSqrtPriceAtTick(config.tickUpper), + uint128(liquidity) + ); + + bytes memory calls = + getMintEncoded(config, liquidity, 1 wei, MAX_SLIPPAGE_INCREASE, ActionConstants.MSG_SENDER, ZERO_BYTES); + + vm.expectRevert(abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, 1 wei, amount0 + 1)); + lpm.modifyLiquidities(calls, _deadline); + } + + function test_mint_slippage_revertAmount1() public { + _test_mint_slippage_revertAmount1(key0); + _test_mint_slippage_revertAmount1(key1); + _test_mint_slippage_revertAmount1(key2); + } + + function _test_mint_slippage_revertAmount1(PoolKey memory key) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 1e18; + (, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(config.tickLower), + TickMath.getSqrtPriceAtTick(config.tickUpper), + uint128(liquidity) + ); + + bytes memory calls = + getMintEncoded(config, liquidity, MAX_SLIPPAGE_INCREASE, 1 wei, ActionConstants.MSG_SENDER, ZERO_BYTES); + + vm.expectRevert(abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, 1 wei, amount1 + 1)); + lpm.modifyLiquidities(calls, _deadline); + } + + function test_mint_slippage_exactDoesNotRevert() public { + _test_mint_slippage_exactDoesNotRevert(key0); + _test_mint_slippage_exactDoesNotRevert(key1); + _test_mint_slippage_exactDoesNotRevert(key2); + } + + function _test_mint_slippage_exactDoesNotRevert(PoolKey memory key) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 1e18; + (uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(config.tickLower), + TickMath.getSqrtPriceAtTick(config.tickUpper), + uint128(liquidity) + ); + uint128 slippage = uint128(amount0) + 1; + + uint256 balance0ManagerBefore = key.currency0.balanceOf(address(manager)); + uint256 balance1ManagerBefore = key.currency1.balanceOf(address(manager)); + + assertEq(amount0, amount1); // symmetric liquidity + + bytes memory calls = + getMintEncoded(config, liquidity, slippage, slippage, ActionConstants.MSG_SENDER, ZERO_BYTES); + + lpm.modifyLiquidities(calls, _deadline); + + uint256 balance0ManagerAfter = key.currency0.balanceOf(address(manager)); + uint256 balance1ManagerAfter = key.currency1.balanceOf(address(manager)); + + assertEq(balance0ManagerAfter - balance0ManagerBefore, slippage); + assertEq(balance1ManagerAfter - balance1ManagerBefore, slippage); + } + + function test_mint_slippage_revert_swap() public { + _test_mint_slippage_revert_swap(key0); + _test_mint_slippage_revert_swap(key1); + _test_mint_slippage_revert_swap(key2); + } + + function _test_mint_slippage_revert_swap(PoolKey memory key) internal { + // swapping will cause a slippage revert + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 100e18; + (uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(config.tickLower), + TickMath.getSqrtPriceAtTick(config.tickUpper), + uint128(liquidity) + ); + uint128 slippage = uint128(amount0) + 1; + + assertEq(amount0, amount1); // symmetric liquidity + + bytes memory calls = + getMintEncoded(config, liquidity, slippage, slippage, ActionConstants.MSG_SENDER, ZERO_BYTES); + + // swap to move the price and cause a slippage revert + swap(key, true, -1e18); + + vm.expectRevert( + abi.encodeWithSelector(SlippageCheck.MaximumAmountExceeded.selector, slippage, 1199947202932782783) + ); + lpm.modifyLiquidities(calls, _deadline); + } + + function test_permissioned_mint_allowed_user() public { + _test_permissioned_mint_allowed_user(key0); + _test_permissioned_mint_allowed_user(key1); + _test_permissioned_mint_allowed_user(key2); + } + + function _test_permissioned_mint_allowed_user(PoolKey memory key) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 1e18; + uint256 tokenId = lpm.nextTokenId(); + + // Alice is in the allowlist, so she should be able to mint + vm.prank(alice); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + } + + function test_permissioned_mint_alt_posm_diff_hooks_reverts() public { + // secondary posm uses different hooks contract + _test_permissioned_mint_alt_posm_diff_hooks_reverts(key0, secondaryPosm); + _test_permissioned_mint_alt_posm_diff_hooks_reverts(key1, secondaryPosm); + _test_permissioned_mint_alt_posm_diff_hooks_reverts(key2, secondaryPosm); + } + + function _test_permissioned_mint_alt_posm_diff_hooks_reverts(PoolKey memory key, IPositionManager posm) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 1e18; + + // we don't use the helper, so we can choose which position manager to use + bytes memory calls = getMintEncoded(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + + vm.prank(alice); + vm.expectRevert(InvalidHook.selector); + posm.modifyLiquidities(calls, block.timestamp + 1); + } + + function test_permissioned_mint_alt_posm_same_hooks_reverts() public { + // tertiary posm uses the same hooks contract + _test_permissioned_mint_alt_posm_same_hooks_reverts(key0, tertiaryPosm); + _test_permissioned_mint_alt_posm_same_hooks_reverts(key1, tertiaryPosm); + _test_permissioned_mint_alt_posm_same_hooks_reverts(key2, tertiaryPosm); + } + + function _test_permissioned_mint_alt_posm_same_hooks_reverts(PoolKey memory key, IPositionManager posm) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 1e18; + + // we don't use the helper, so we can choose which position manager to use + bytes memory calls = getMintEncoded(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(permissionedHooks), + IHooks.beforeAddLiquidity.selector, + abi.encodeWithSelector(Unauthorized.selector), + abi.encodeWithSelector(HookCallFailed.selector) + ) + ); + posm.modifyLiquidities(calls, block.timestamp + 1); + } + + function test_permissioned_mint_disallowed_user_reverts() public { + _test_permissioned_mint_disallowed_user_reverts(key0); + _test_permissioned_mint_disallowed_user_reverts(key1); + _test_permissioned_mint_disallowed_user_reverts(key2); + } + + function _test_permissioned_mint_disallowed_user_reverts(PoolKey memory key) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 1e18; + + // Add some tokens to unauthorized user + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(unauthorizedUser, PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency0)).mint(unauthorizedUser, 1000e18); + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(unauthorizedUser, PermissionFlags.NONE); + MockERC20(Currency.unwrap(currency1)).mint(unauthorizedUser, 1000e18); + + vm.startPrank(unauthorizedUser); + + // Approve tokens for the position manager + originalToken0.approve(address(permit2), type(uint256).max); + MockERC20(Currency.unwrap(currency1)).approve(address(permit2), type(uint256).max); + permit2.approve(address(originalToken0), address(lpm), type(uint160).max, type(uint48).max); + permit2.approve(Currency.unwrap(currency1), address(lpm), type(uint160).max, type(uint48).max); + + // This should revert because the recipient is not in the allowlist + vm.expectRevert(Unauthorized.selector); + mint(config, liquidity, unauthorizedUser, ZERO_BYTES); + vm.stopPrank(); + } + + function test_permissioned_mint_increase_allowed_user() public { + _test_permissioned_mint_increase_allowed_user(key0); + _test_permissioned_mint_increase_allowed_user(key1); + _test_permissioned_mint_increase_allowed_user(key2); + } + + function _test_permissioned_mint_increase_allowed_user(PoolKey memory key) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 1e18; + uint256 tokenId = lpm.nextTokenId(); + + vm.startPrank(alice); + + // add initial liquidity + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + + tokenId = lpm.nextTokenId(); + + // Increase liquidity + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + vm.stopPrank(); + + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + } + + function test_permissioned_mint_increase_disallowed_user_reverts() public { + _test_permissioned_mint_increase_disallowed_user_reverts(key0); + _test_permissioned_mint_increase_disallowed_user_reverts(key1); + _test_permissioned_mint_increase_disallowed_user_reverts(key2); + } + + function _test_permissioned_mint_increase_disallowed_user_reverts(PoolKey memory key) internal { + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: -120, tickUpper: 120}); + + uint256 liquidity = 1e17; + uint256 tokenId = lpm.nextTokenId(); + + vm.startPrank(alice); + + // add initial liquidity + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.NONE); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.NONE); + + tokenId = lpm.nextTokenId(); + + // Increasing liquidity should revert because the recipient is no longer in the allowlist + vm.expectRevert(Unauthorized.selector); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + vm.stopPrank(); + + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.ALL_ALLOWED); + } + + function test_unpermissioned_sided_mint_disallowed_user_reverts() public { + _test_unpermissioned_sided_mint_disallowed_user_reverts(key0, false); + _test_unpermissioned_sided_mint_disallowed_user_reverts(key1, true); + // key2 has no unpermissioned side + } + + function _test_unpermissioned_sided_mint_disallowed_user_reverts(PoolKey memory key, bool useZero) internal { + PositionConfig memory config; + Currency currencyUnpermissioned; + + uint256 liquidity = 1e18; + + if (useZero) { + currencyUnpermissioned = getPermissionedCurrency(key.currency0); + config = PositionConfig({poolKey: key, tickLower: -180, tickUpper: -60}); + } else { + currencyUnpermissioned = getPermissionedCurrency(key.currency1); + config = PositionConfig({poolKey: key, tickLower: 60, tickUpper: 180}); + } + + // Add some tokens to unauthorized user + MockERC20(Currency.unwrap(currencyUnpermissioned)).mint(unauthorizedUser, 1000e18); + + vm.startPrank(unauthorizedUser); + + // Approve tokens for the position manager and permit2 + MockERC20(Currency.unwrap(currencyUnpermissioned)).approve(address(permit2), type(uint256).max); + permit2.approve(Currency.unwrap(currencyUnpermissioned), address(lpm), type(uint160).max, type(uint48).max); + + // This should revert because the recipient is not in the allowlist + vm.expectRevert(Unauthorized.selector); + mint(config, liquidity, unauthorizedUser, ZERO_BYTES); + vm.stopPrank(); + } + + function test_unpermissioned_sided_mint_allowed_user() public { + _test_unpermissioned_sided_mint_allowed_user(key0, false); + _test_unpermissioned_sided_mint_allowed_user(key1, true); + // key2 has no unpermissioned side + } + + function _test_unpermissioned_sided_mint_allowed_user(PoolKey memory key, bool useZero) internal { + PositionConfig memory config; + Currency currencyPermissioned; + + uint256 liquidity = 1e18; + uint256 tokenId = lpm.nextTokenId(); + + if (useZero) { + currencyPermissioned = getPermissionedCurrency(key.currency0); + config = PositionConfig({poolKey: key, tickLower: -180, tickUpper: -60}); + } else { + currencyPermissioned = getPermissionedCurrency(key.currency1); + config = PositionConfig({poolKey: key, tickLower: 60, tickUpper: 180}); + } + + vm.startPrank(alice); + + // Approve tokens for the position manager and permit2 + MockERC20(Currency.unwrap(currencyPermissioned)).approve(address(permit2), type(uint256).max); + permit2.approve(Currency.unwrap(currencyPermissioned), address(lpm), type(uint160).max, type(uint48).max); + + mint(config, liquidity, alice, ZERO_BYTES); + vm.stopPrank(); + + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + } + + function test_permissioned_sided_mint_disallowed_user_reverts() public { + _test_permissioned_sided_mint_disallowed_user_reverts(key0, true); + _test_permissioned_sided_mint_disallowed_user_reverts(key1, false); + _test_permissioned_sided_mint_disallowed_user_reverts(key2, true); + } + + function _test_permissioned_sided_mint_disallowed_user_reverts(PoolKey memory key, bool useZero) internal { + PositionConfig memory config; + Currency currencyPermissioned; + + uint256 liquidity = 1e18; + + if (useZero) { + currencyPermissioned = getPermissionedCurrency(key.currency0); + config = PositionConfig({poolKey: key, tickLower: -180, tickUpper: -60}); + } else { + currencyPermissioned = getPermissionedCurrency(key.currency1); + config = PositionConfig({poolKey: key, tickLower: 60, tickUpper: 180}); + } + + // Add some tokens to unauthorized user + MockPermissionedToken(Currency.unwrap(currencyPermissioned)) + .setAllowlist(unauthorizedUser, PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currencyPermissioned)).mint(unauthorizedUser, 1000e18); + MockPermissionedToken(Currency.unwrap(currencyPermissioned)) + .setAllowlist(unauthorizedUser, PermissionFlags.NONE); + + vm.startPrank(unauthorizedUser); + + // Approve tokens for the position manager and permit2 + MockERC20(Currency.unwrap(currencyPermissioned)).approve(address(permit2), type(uint256).max); + permit2.approve(Currency.unwrap(currencyPermissioned), address(lpm), type(uint160).max, type(uint48).max); + + // This should revert because the recipient is not in the allowlist + vm.expectRevert(Unauthorized.selector); + mint(config, liquidity, unauthorizedUser, ZERO_BYTES); + vm.stopPrank(); + } + + function test_permissioned_single_sided_mint_allowed_user() public { + _test_permissioned_sided_mint_allowed_user(key0, true); + _test_permissioned_sided_mint_allowed_user(key1, false); + _test_permissioned_sided_mint_allowed_user(key2, true); + } + + function _test_permissioned_sided_mint_allowed_user(PoolKey memory key, bool useZero) internal { + PositionConfig memory config; + + uint256 liquidity = 1e18; + uint256 tokenId = lpm.nextTokenId(); + + if (useZero) { + config = PositionConfig({poolKey: key, tickLower: -180, tickUpper: -60}); + } else { + config = PositionConfig({poolKey: key, tickLower: 60, tickUpper: 180}); + } + + vm.prank(alice); + mint(config, liquidity, alice, ZERO_BYTES); + + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + } + + function test_fuzz_burn_emptyPosition(ModifyLiquidityParams memory params) public { + _test_fuzz_burn_emptyPosition(key0, params); + _test_fuzz_burn_emptyPosition(key1, params); + _test_fuzz_burn_emptyPosition(key2, params); + } + + function _test_fuzz_burn_emptyPosition(PoolKey memory key, ModifyLiquidityParams memory params) internal { + uint256 balance0Start = key.currency0.balanceOfSelf(); + uint256 balance1Start = key.currency1.balanceOfSelf(); + uint256 tokenId; + + // create liquidity we can burn + (tokenId, params) = addFuzzyLiquidity(lpm, ActionConstants.MSG_SENDER, key, params, SQRT_PRICE_1_1, ZERO_BYTES); + PositionConfig memory config = + PositionConfig({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + assertEq(tokenId, lpm.nextTokenId() - 1); + assertEq(IERC721(address(lpm)).ownerOf(1), address(this)); + + uint256 liquidity = lpm.getPositionLiquidity(tokenId); + + assertEq(liquidity, uint256(params.liquidityDelta)); + + uint256 balance0BeforeBurn = getPermissionedCurrency(key.currency0).balanceOfSelf(); + uint256 balance1BeforeBurn = getPermissionedCurrency(key.currency1).balanceOfSelf(); + uint256 balance0ManagerBefore = (key.currency0).balanceOf(address(manager)); + uint256 balance1ManagerBefore = (key.currency1).balanceOf(address(manager)); + + // burn liquidity + decreaseLiquidity(tokenId, config, liquidity, ZERO_BYTES); + + uint256 balance0ManagerAfter = (key.currency0).balanceOf(address(manager)); + uint256 balance1ManagerAfter = (key.currency1).balanceOf(address(manager)); + + liquidity = lpm.getPositionLiquidity(tokenId); + + assertEq(liquidity, 0); + assertEq( + getPermissionedCurrency(key.currency0).balanceOfSelf(), + balance0BeforeBurn + balance0ManagerBefore - balance0ManagerAfter + ); + assertEq( + getPermissionedCurrency(key.currency1).balanceOfSelf(), + balance1BeforeBurn + balance1ManagerBefore - balance1ManagerAfter + ); + + IERC721(address(lpm)).ownerOf(lpm.nextTokenId() - 1); + + // see note from position manager test: "no tokens were lost ... fuzzer showing off by 1 sometimes" + assertApproxEqAbs(key.currency0.balanceOfSelf(), balance0Start, 1 wei); + assertApproxEqAbs(key.currency1.balanceOfSelf(), balance1Start, 1 wei); + } + + function test_initialize() public { + _test_initialize(currency0, currency1); + _test_initialize(Currency.wrap(address(permissionsAdapter0)), currency1); + _test_initialize(currency1, Currency.wrap(address(permissionsAdapter2))); + _test_initialize(Currency.wrap(address(permissionsAdapter0)), Currency.wrap(address(permissionsAdapter2))); + } + + function _test_initialize(Currency currency0_, Currency currency1_) internal { + // initialize a new pool + PoolKey memory key = PoolKey({ + currency0: currency0_, currency1: currency1_, fee: 0, tickSpacing: 100, hooks: IHooks(address(0)) + }); + + lpm.initializePool(key, SQRT_PRICE_1_1); + + (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_fuzz_initialize(uint160 sqrtPrice, uint24 fee) public { + _test_fuzz_initialize(currency0, currency1, sqrtPrice, fee); + _test_fuzz_initialize(Currency.wrap(address(permissionsAdapter0)), currency1, sqrtPrice, fee); + _test_fuzz_initialize(currency1, Currency.wrap(address(permissionsAdapter2)), sqrtPrice, fee); + _test_fuzz_initialize( + Currency.wrap(address(permissionsAdapter0)), Currency.wrap(address(permissionsAdapter2)), sqrtPrice, fee + ); + } + + function _test_fuzz_initialize(Currency currency0_, Currency currency1_, uint160 sqrtPrice, uint24 fee) internal { + sqrtPrice = + uint160(bound(sqrtPrice, TickMath.MIN_SQRT_PRICE, TickMath.MAX_SQRT_PRICE_MINUS_MIN_SQRT_PRICE_MINUS_ONE)); + fee = uint24(bound(fee, 0, LPFeeLibrary.MAX_LP_FEE)); + PoolKey memory key = PoolKey({ + currency0: currency0_, currency1: currency1_, fee: fee, tickSpacing: 10, hooks: IHooks(address(0)) + }); + + lpm.initializePool(key, sqrtPrice); + + (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee) = manager.getSlot0(key.toId()); + + assertEq(sqrtPriceX96, sqrtPrice); + assertEq(tick, TickMath.getTickAtSqrtPrice(sqrtPrice)); + assertEq(protocolFee, 0); + assertEq(lpFee, fee); + } + + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + + function test_transferFrom_success_seize_by_admin() public { + _testRevert_transferFrom_success_seize_by_admin(key0); + _testRevert_transferFrom_success_seize_by_admin(key1); + } + + function _testRevert_transferFrom_success_seize_by_admin(PoolKey memory poolKey) internal { + uint256 tokenId = lpm.nextTokenId(); + _test_permissioned_mint_allowed_user(poolKey); + + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(this), tokenId); + // address(this) is admin + IERC721(address(lpm)).transferFrom(alice, address(this), tokenId); + } + + function test_transferFrom_success_seize_by_either_admin() public { + uint256 tokenId0 = lpm.nextTokenId(); + _test_permissioned_mint_allowed_user(key2); + uint256 tokenId1 = lpm.nextTokenId(); + _test_permissioned_mint_allowed_user(key2); + uint256 tokenId2 = lpm.nextTokenId(); + _test_permissioned_mint_allowed_user(key2); + + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(this), tokenId0); + // address(this) is admin of both permissioned tokens + IERC721(address(lpm)).transferFrom(alice, address(this), tokenId0); + + address owner0 = makeAddr("owner0"); + address owner1 = makeAddr("owner1"); + permissionsAdapter0.transferOwnership(owner0); + vm.prank(owner0); + permissionsAdapter0.acceptOwnership(); + permissionsAdapter2.transferOwnership(owner1); + vm.prank(owner1); + permissionsAdapter2.acceptOwnership(); + + vm.prank(owner0); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(this), tokenId1); + // owner0 is admin of permissionsAdapter0 + IERC721(address(lpm)).transferFrom(alice, address(this), tokenId1); + + vm.prank(owner1); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(this), tokenId2); + // owner1 is admin of permissionsAdapter2 + IERC721(address(lpm)).transferFrom(alice, address(this), tokenId2); + } + + function test_transferFrom_revert_not_admin(address notAdmin) public { + vm.assume(notAdmin != address(this) && notAdmin != address(0)); + uint256 tokenId0 = lpm.nextTokenId(); + _test_permissioned_mint_allowed_user(key0); + uint256 tokenId1 = lpm.nextTokenId(); + _test_permissioned_mint_allowed_user(key1); + uint256 tokenId2 = lpm.nextTokenId(); + _test_permissioned_mint_allowed_user(key2); + uint256 tokenId3 = lpm.nextTokenId(); + _test_permissioned_mint_allowed_user(key2); + + vm.prank(notAdmin); + vm.expectRevert(Unauthorized.selector); + IERC721(address(lpm)).transferFrom(alice, address(this), tokenId0); + + vm.prank(notAdmin); + vm.expectRevert(Unauthorized.selector); + IERC721(address(lpm)).transferFrom(alice, address(this), tokenId1); + + vm.prank(notAdmin); + vm.expectRevert(Unauthorized.selector); + IERC721(address(lpm)).transferFrom(alice, address(this), tokenId2); + + vm.prank(notAdmin); + vm.expectRevert(Unauthorized.selector); + IERC721(address(lpm)).transferFrom(alice, address(this), tokenId3); + } + + error SafeTransferDisabled(); + + function test_safe_transfer_from_reverts() public { + bytes memory encodedCall = + abi.encodeWithSignature("safeTransferFrom(address,address,uint256)", alice, address(this), 1); + + address target = address(lpm); + + vm.startPrank(alice); + (bool success, bytes memory data) = address(target).call(encodedCall); + + assertEq(success, false); + assertEq(bytes4(data), SafeTransferDisabled.selector); + vm.stopPrank(); + } + + function test_safe_transfer_from_with_bytes_reverts() public { + bytes memory encodedCall = + abi.encodeWithSignature("safeTransferFrom(address,address,uint256,bytes)", alice, address(this), 1, "abcd"); + address target = address(lpm); + + vm.startPrank(alice); + (bool success, bytes memory data) = address(target).call(encodedCall); + + assertEq(success, false); + assertEq(bytes4(data), SafeTransferDisabled.selector); + vm.stopPrank(); + } + + function test_mint_from_contract_balance() public { + _test_mint_from_contract_balance(key0); + _test_mint_from_contract_balance(key1); + _test_mint_from_contract_balance(key2); + } + + /// @dev This function had to be split up and refactored to avoid stack-too-deep errors + function _test_mint_from_contract_balance(PoolKey memory key) internal { + uint256 amount0ToTransfer = 100e18; + uint256 amount1ToTransfer = 100e18; + + // Setup and transfer tokens + setupContractBalance(key, amount0ToTransfer, amount1ToTransfer); + + // Get balances before minting + BalanceInfo memory balanceInfo = getBalanceInfo(key); + + // Create and execute mint plan + bytes memory calls = createMintPlan(key, amount0ToTransfer, amount1ToTransfer); + uint256 tokenId = lpm.nextTokenId(); + lpm.modifyLiquidities(calls, _deadline); + + // Verify results + verifyMintResults(key, balanceInfo, tokenId); + } + + function test_mint_from_contract_balance_disallowed_revert() public { + _test_mint_from_contract_balance_disallowed_revert(keyFake0); + _test_mint_from_contract_balance_disallowed_revert(keyFake1); + _test_mint_from_contract_balance_disallowed_revert(keyFake2); + } + + function _test_mint_from_contract_balance_disallowed_revert(PoolKey memory key) internal { + uint256 amount0ToTransfer = 1e18; + uint256 amount1ToTransfer = 1e18; + + Currency currency0_ = getPermissionedCurrency(key.currency0); + Currency currency1_ = getPermissionedCurrency(key.currency1); + + uint256 balance0Before = currency0_.balanceOf(address(secondaryPosm)); + uint256 balance1Before = currency1_.balanceOf(address(secondaryPosm)); + + // Transfer tokens to the contract + currency0_.transfer(address(secondaryPosm), amount0ToTransfer); + currency1_.transfer(address(secondaryPosm), amount1ToTransfer); + + // Calculate liquidity for the desired amounts + int24 tickLower = -int24(key.tickSpacing); + int24 tickUpper = int24(key.tickSpacing); + uint256 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(tickLower), + TickMath.getSqrtPriceAtTick(tickUpper), + amount0ToTransfer, + amount1ToTransfer + ); + + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); + + // Verify the contract has the tokens + assertEq(currency0_.balanceOf(address(secondaryPosm)), amount0ToTransfer + balance0Before); + assertEq(currency1_.balanceOf(address(secondaryPosm)), amount1ToTransfer + balance1Before); + + // Create a plan that uses the contract's balance instead of the caller's + Plan memory planner = Planner.init(); + planner.add( + Actions.MINT_POSITION, + abi.encode( + config.poolKey, + config.tickLower, + config.tickUpper, + liquidityToAdd, + MAX_SLIPPAGE_INCREASE, + MAX_SLIPPAGE_INCREASE, + address(this), // recipient + ZERO_BYTES // hookData + ) + ); + + // Add actions to settle from the contract's balance + planner.add(Actions.SETTLE, abi.encode(key.currency0, ActionConstants.OPEN_DELTA, false)); // false = payer is contract + planner.add(Actions.SETTLE, abi.encode(key.currency1, ActionConstants.OPEN_DELTA, false)); // false = payer is contract + + bytes memory calls = planner.finalizeModifyLiquidityWithClose(config.poolKey); + + vm.prank(unauthorizedUser); + vm.expectRevert(); + secondaryPosm.modifyLiquidities(calls, _deadline); + } + + function test_mint_from_contract_balance_uses_contract_balance_settle() public { + _test_mint_from_contract_balance_uses_contract_balance_settle(key0); + _test_mint_from_contract_balance_uses_contract_balance_settle(key1); + _test_mint_from_contract_balance_uses_contract_balance_settle(key2); + } + + function _test_mint_from_contract_balance_uses_contract_balance_settle(PoolKey memory key) internal { + uint256 amount0ToTransfer = 100e18; + uint256 amount1ToTransfer = 100e18; + + // Transfer underlying permissioned tokens to the POSM + setupContractBalance(key, amount0ToTransfer, amount1ToTransfer); + + BalanceInfo memory balanceInfo = getBalanceInfo(key); + + // Create a mint plan that uses CONTRACT_BALANCE instead of OPEN_DELTA + int24 tickLower = -int24(key.tickSpacing); + int24 tickUpper = int24(key.tickSpacing); + uint256 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(tickLower), + TickMath.getSqrtPriceAtTick(tickUpper), + amount0ToTransfer, + amount1ToTransfer + ); + + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); + + Plan memory planner = Planner.init(); + planner.add( + Actions.MINT_POSITION, + abi.encode( + config.poolKey, + config.tickLower, + config.tickUpper, + liquidityToAdd, + MAX_SLIPPAGE_INCREASE, + MAX_SLIPPAGE_INCREASE, + address(this), + ZERO_BYTES + ) + ); + + // Use CONTRACT_BALANCE instead of OPEN_DELTA — this is the broken path + planner.add(Actions.SETTLE, abi.encode(key.currency0, ActionConstants.CONTRACT_BALANCE, false)); + planner.add(Actions.SETTLE, abi.encode(key.currency1, ActionConstants.CONTRACT_BALANCE, false)); + + bytes memory calls = planner.finalizeModifyLiquidityWithClose(config.poolKey); + uint256 tokenId = lpm.nextTokenId(); + lpm.modifyLiquidities(calls, _deadline); + + verifyMintResults(key, balanceInfo, tokenId); + } + + // ===== PERMISSION FLAG TESTS ===== + + function test_permission_flag_none() public { + // Test that NONE permissions prevent all operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.NONE); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.NONE); + + // Should revert when trying to mint with NONE permissions + PositionConfig memory config = PositionConfig({poolKey: key2, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + + vm.prank(alice); + vm.expectRevert(); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + } + + function test_permission_flag_swap_allowed() public { + // Test that SWAP_ALLOWED only allows swaps, not liquidity operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + + // Should revert when trying to mint (liquidity operation) with only SWAP_ALLOWED + PositionConfig memory config = PositionConfig({poolKey: key2, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + + vm.prank(alice); + vm.expectRevert(); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + } + + function test_permission_flag_liquidity_allowed() public { + // Test that LIQUIDITY_ALLOWED allows liquidity operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + + // Should succeed when trying to mint with LIQUIDITY_ALLOWED + PositionConfig memory config = PositionConfig({poolKey: key2, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + uint256 tokenId = lpm.nextTokenId(); + + vm.prank(alice); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + } + + function test_permission_flag_all_allowed() public { + // Test that ALL_ALLOWED allows all operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.ALL_ALLOWED); + + // Should succeed when trying to mint with ALL_ALLOWED + PositionConfig memory config = PositionConfig({poolKey: key2, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + uint256 tokenId = lpm.nextTokenId(); + + vm.prank(alice); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + } + + function test_permission_flag_combinations() public { + // Test various combinations of permissions + PositionConfig memory config = PositionConfig({poolKey: key2, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + + // Test SWAP_ALLOWED + LIQUIDITY_ALLOWED (should work like ALL_ALLOWED) + MockPermissionedToken(Currency.unwrap(currency0)) + .setAllowlist(alice, (PermissionFlags.SWAP_ALLOWED | PermissionFlags.LIQUIDITY_ALLOWED)); + MockPermissionedToken(Currency.unwrap(currency2)) + .setAllowlist(alice, (PermissionFlags.SWAP_ALLOWED | PermissionFlags.LIQUIDITY_ALLOWED)); + + uint256 tokenId = lpm.nextTokenId(); + vm.prank(alice); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + } + + function test_permission_flag_partial_permissions() public { + // Test that having permissions on only one token is enough + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.NONE); + + PositionConfig memory config = PositionConfig({poolKey: key2, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + + vm.prank(alice); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + } + + function test_permission_flag_dynamic_changes() public { + // Test that permission changes take effect immediately + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + + PositionConfig memory config = PositionConfig({poolKey: key2, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + + // Should succeed initially + uint256 tokenId = lpm.nextTokenId(); + vm.prank(alice); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + + // Remove permissions + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.NONE); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.NONE); + + // Should fail on subsequent operations + tokenId = lpm.nextTokenId(); + vm.prank(alice); + vm.expectRevert(); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + } + + function test_permission_flag_edge_cases() public { + // Test edge cases with permission flags + PositionConfig memory config = PositionConfig({poolKey: key2, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + + // Test with zero permissions (should be same as NONE) + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlag.wrap(0x0000)); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlag.wrap(0x0000)); + + vm.prank(alice); + vm.expectRevert(); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + + // Test with maximum permissions (should be same as ALL_ALLOWED) + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlag.wrap(0xFFFF)); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlag.wrap(0xFFFF)); + + uint256 tokenId = lpm.nextTokenId(); + vm.prank(alice); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + } + + function test_permission_flag_all_pools() public { + // Test permission flags work across all pool types + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + + PositionConfig memory config0 = PositionConfig({poolKey: key0, tickLower: -120, tickUpper: 120}); + PositionConfig memory config1 = PositionConfig({poolKey: key1, tickLower: -120, tickUpper: 120}); + PositionConfig memory config2 = PositionConfig({poolKey: key2, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + + // Test permissioned/normal pool + uint256 tokenId0 = lpm.nextTokenId(); + vm.prank(alice); + mint(config0, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + assertEq(IERC721(address(lpm)).ownerOf(tokenId0), alice); + + // Test normal/permissioned pool + uint256 tokenId1 = lpm.nextTokenId(); + vm.prank(alice); + mint(config1, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + assertEq(IERC721(address(lpm)).ownerOf(tokenId1), alice); + + // Test permissioned/permissioned pool + uint256 tokenId2 = lpm.nextTokenId(); + vm.prank(alice); + mint(config2, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + assertEq(IERC721(address(lpm)).ownerOf(tokenId2), alice); + } + + function test_permission_flag_swap_allowed_unauthorized_reverts() public { + // Test that SWAP_ALLOWED does not allow liquidity operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency2)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + + // Should revert when trying to mint with SWAP_ALLOWED + PositionConfig memory config = PositionConfig({poolKey: insecureKey, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + + vm.startPrank(alice); + vm.expectRevert(Unauthorized.selector); + mint(config, liquidity, ActionConstants.MSG_SENDER, ZERO_BYTES); + vm.stopPrank(); + } + + function test_permissioned_mint_to_unauthorized_recipient_reverts() public { + // Caller (address(this)) is allowed, but unauthorizedUser is not + // The hook will pass because the caller is authorized, + // but _mint should revert because the recipient lacks LIQUIDITY_ALLOWED + PositionConfig memory config = PositionConfig({poolKey: key0, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + + vm.expectRevert(Unauthorized.selector); + mint(config, liquidity, unauthorizedUser, ZERO_BYTES); + } + + function test_permissioned_mint_to_authorized_recipient_succeeds() public { + // Caller (address(this)) is allowed, alice is also allowed + // Minting to an authorized recipient should succeed + PositionConfig memory config = PositionConfig({poolKey: key0, tickLower: -120, tickUpper: 120}); + uint256 liquidity = 1e18; + uint256 tokenId = lpm.nextTokenId(); + + mint(config, liquidity, alice, ZERO_BYTES); + assertEq(IERC721(address(lpm)).ownerOf(tokenId), alice); + } +} diff --git a/test/hooks/permissionedPools/PermissionedV4Router.t.sol b/test/hooks/permissionedPools/PermissionedV4Router.t.sol new file mode 100644 index 000000000..6d49721f5 --- /dev/null +++ b/test/hooks/permissionedPools/PermissionedV4Router.t.sol @@ -0,0 +1,2048 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {ModifyLiquidityParams, SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {CustomRevert} from "@uniswap/v4-core/src/libraries/CustomRevert.sol"; +import {Hooks, IHooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {PermitHash} from "permit2/src/libraries/PermitHash.sol"; +import {IV4Router} from "../../../src/interfaces/IV4Router.sol"; +import {PermissionedRoutingTestHelpers} from "./shared/PermissionedRoutingTestHelpers.sol"; +import {Planner} from "../../shared/Planner.sol"; +import {Actions} from "../../../src/libraries/Actions.sol"; +import {ActionConstants} from "../../../src/libraries/ActionConstants.sol"; +import {MockPermissionedToken, MockAllowlistChecker} from "./PermissionedPoolsBase.sol"; +import {PermissionFlags, PermissionFlag} from "../../../src/hooks/permissionedPools/libraries/PermissionFlags.sol"; +import {PathKey} from "../../../src/libraries/PathKey.sol"; + +contract PermissionedV4RouterTest is PermissionedRoutingTestHelpers { + using PermitHash for IAllowanceTransfer.PermitSingle; + + // To allow testing without importing PermissionedV4Router + error HookNotImplemented(); + error Unauthorized(); + error HookCallFailed(); + error SliceOutOfBounds(); + error NoVerifiedAdapter(); + error InvalidCommandType(uint256 commandType); + error ExecutionFailed(uint256 commandIndex, bytes output); + + Currency public permissionsAdapter0Currency; + Currency public permissionsAdapter1Currency; + + // Test Users + address public alice = makeAddr("ALICE"); + address public unauthorizedUser = makeAddr("UNAUTHORIZED"); + + // Commands + bytes public COMMAND_V4_SWAP = hex"10"; + bytes public COMMAND_PERMIT2_PERMIT = hex"0a"; + + function setUp() public { + setupPermissionedRouterCurrenciesAndPoolsWithLiquidity(alice); + + permissionsAdapter0Currency = Currency.wrap(address(permissionsAdapter0)); + permissionsAdapter1Currency = Currency.wrap(address(permissionsAdapter1)); + permissionsAdapter0.updateSwappingEnabled(true); + permissionsAdapter1.updateSwappingEnabled(true); + + plan = Planner.init(); + + setupPermissionsAndApprovals(); + } + + function setupPermissionsAndApprovals() internal { + // Setup approvals for test address + _setupApprovals(); + + // Setup approvals for authorized user + vm.startPrank(alice); + _setupApprovals(); + vm.stopPrank(); + } + + function _setupApprovals() internal { + IERC20(Currency.unwrap(currency0)).approve(address(permit2), type(uint160).max); + IERC20(Currency.unwrap(currency1)).approve(address(permit2), type(uint160).max); + IERC20(Currency.unwrap(currency0)).approve(address(permissionedRouter), type(uint160).max); + IERC20(Currency.unwrap(currency1)).approve(address(permissionedRouter), type(uint160).max); + IERC20(Currency.unwrap(currency0)).approve(address(permissionedHooks), type(uint160).max); + IERC20(Currency.unwrap(currency1)).approve(address(permissionedHooks), type(uint160).max); + IERC20(Currency.unwrap(currency0)).approve(address(positionManager), type(uint160).max); + IERC20(Currency.unwrap(currency1)).approve(address(positionManager), type(uint160).max); + + permit2.approve(Currency.unwrap(currency0), address(permissionedRouter), type(uint160).max, 2 ** 47); + permit2.approve(Currency.unwrap(currency1), address(permissionedRouter), type(uint160).max, 2 ** 47); + permit2.approve(Currency.unwrap(currency0), address(permissionedHooks), type(uint160).max, 2 ** 47); + permit2.approve(Currency.unwrap(currency1), address(permissionedHooks), type(uint160).max, 2 ** 47); + permit2.approve(Currency.unwrap(currency0), address(positionManager), type(uint160).max, 2 ** 47); + permit2.approve(Currency.unwrap(currency1), address(positionManager), type(uint160).max, 2 ** 47); + } + + function getInputAndOutputBalances(PoolKey memory poolKey, bool zeroForOne, address manager_) + public + view + returns (uint256 inputBalance, uint256 outputBalance, uint256 ethBalance) + { + if (zeroForOne) { + inputBalance = poolKey.currency0.balanceOf(manager_); + outputBalance = poolKey.currency1.balanceOf(manager_); + } else { + inputBalance = poolKey.currency1.balanceOf(manager_); + outputBalance = poolKey.currency0.balanceOf(manager_); + } + ethBalance = address(this).balance; + } + + function getInputAndOutputBalancesPath(Currency[] memory path, address manager_) + public + view + returns (uint256 inputBalance, uint256 outputBalance, uint256 ethBalance) + { + inputBalance = path[0].balanceOf(manager_); + outputBalance = path[path.length - 1].balanceOf(manager_); + ethBalance = address(this).balance; + } + + function test_gas_swapExactInputSingle_permissionedTokens() public { + uint256 amountIn = 1000; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + vm.snapshotGasLastCall("PermissionedV4Router_ExactInputSingle_PermissionedTokens"); + } + + /*////////////////////////////////////////////////////////////// + PERMISSION TESTS + //////////////////////////////////////////////////////////////*/ + + function test_swap_reverts_unauthorized_user() public { + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(unauthorizedUser, PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(unauthorizedUser, PermissionFlags.ALL_ALLOWED); + IERC20(Currency.unwrap(currency0)).transfer(unauthorizedUser, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(unauthorizedUser, 2 ether); + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(unauthorizedUser, PermissionFlags.NONE); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(unauthorizedUser, PermissionFlags.NONE); + + Currency currencyA = permissionsAdapter0Currency; + Currency currencyB = permissionsAdapter1Currency; + if (Currency.unwrap(currencyA) > Currency.unwrap(currencyB)) (currencyA, currencyB) = (currencyB, currencyA); + + uint256 amountIn = 1 ether; + PoolKey memory adapterKey = PoolKey(currencyA, currencyB, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = plan.finalizeSwap(currencyA, currencyB, ActionConstants.MSG_SENDER); + + vm.prank(unauthorizedUser); + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(permissionedHooks), + IHooks.beforeSwap.selector, + abi.encodeWithSelector(Unauthorized.selector), + abi.encodeWithSelector(HookCallFailed.selector) + ) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + error SwappingDisabled(); + + function test_swap_revert_trading_disabled() public { + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + permissionsAdapter0.updateSwappingEnabled(false); + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(permissionedHooks), + IHooks.beforeSwap.selector, + abi.encodeWithSelector(SwappingDisabled.selector), + abi.encodeWithSelector(Hooks.HookCallFailed.selector) + ) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + + permissionsAdapter0.updateSwappingEnabled(true); + permissionsAdapter1.updateSwappingEnabled(false); + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(permissionedHooks), + IHooks.beforeSwap.selector, + abi.encodeWithSelector(SwappingDisabled.selector), + abi.encodeWithSelector(Hooks.HookCallFailed.selector) + ) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_swap_succeeds_authorized_user() public { + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_swap_authorized_router() public { + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + uint256 expectedAmountOut = 98; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(adapterKey, true, address(manager)); + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(adapterKey, true, address(manager)); + + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_swap_unauthorized_router_reverts() public { + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + permissionsAdapter0.updateAllowedWrapper(address(permissionedRouter), false); + permissionsAdapter1.updateAllowedWrapper(address(permissionedRouter), false); + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(permissionedHooks), + IHooks.beforeSwap.selector, + abi.encodeWithSelector(Unauthorized.selector), + abi.encodeWithSelector(HookCallFailed.selector) + ) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_swap_succeeds_authorized_user_mixed_entry() public { + uint256 amountIn = 100; + uint256 expectedAmountOut = 98; + bool zeroToOne = permissionsAdapter1Currency == key1.currency0; + Currency inputCurrency; + Currency outputCurrency; + + if (zeroToOne) { + inputCurrency = key1.currency0; + outputCurrency = key1.currency1; + getPermissionedCurrency(key1.currency0).transfer(alice, 2 ether); + } else { + inputCurrency = key1.currency1; + outputCurrency = key1.currency0; + getPermissionedCurrency(key1.currency1).transfer(alice, 2 ether); + } + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key1, zeroToOne, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = plan.finalizeSwap(inputCurrency, outputCurrency, ActionConstants.MSG_SENDER); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key1, zeroToOne, address(manager)); + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key1, zeroToOne, address(manager)); + + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_swap_succeeds_authorized_user_mixed_exit() public { + IERC20(Currency.unwrap(currency2)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + uint256 expectedAmountOut = 98; + bool zeroToOne = !(permissionsAdapter1Currency == key1.currency0); + Currency inputCurrency; + Currency outputCurrency; + + if (zeroToOne) { + inputCurrency = key1.currency0; + outputCurrency = key1.currency1; + getPermissionedCurrency(key1.currency0).transfer(alice, 2 ether); + } else { + inputCurrency = key1.currency1; + outputCurrency = key1.currency0; + getPermissionedCurrency(key1.currency1).transfer(alice, 2 ether); + } + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key1, zeroToOne, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = plan.finalizeSwap(inputCurrency, outputCurrency, ActionConstants.MSG_SENDER); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key1, zeroToOne, address(manager)); + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key1, zeroToOne, address(manager)); + + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_swap_succeeds_unauthorized_user_mixed_entry() public { + bool zeroToOne = permissionsAdapter1Currency == key1.currency0; + Currency inputCurrency; + Currency outputCurrency; + + if (zeroToOne) { + inputCurrency = key1.currency0; + outputCurrency = key1.currency1; + getPermissionedCurrency(key1.currency0).transfer(alice, 2 ether); + } else { + inputCurrency = key1.currency1; + outputCurrency = key1.currency0; + getPermissionedCurrency(key1.currency1).transfer(alice, 2 ether); + } + + // Give unauthorized user the permissioned currency + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(unauthorizedUser, PermissionFlags.ALL_ALLOWED); + IERC20(Currency.unwrap(currency1)).transfer(unauthorizedUser, 2 ether); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(unauthorizedUser, PermissionFlags.NONE); + + uint256 amountIn = 100; + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key1, zeroToOne, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = plan.finalizeSwap(inputCurrency, outputCurrency, ActionConstants.MSG_SENDER); + + vm.prank(unauthorizedUser); + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(permissionedHooks), + IHooks.beforeSwap.selector, + abi.encodeWithSelector(Unauthorized.selector), + abi.encodeWithSelector(HookCallFailed.selector) + ) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_swap_unauthorized_user_mixed_exit_reverts() public { + IERC20(Currency.unwrap(currency2)).transfer(unauthorizedUser, 2 ether); + + uint256 amountIn = 100; + bool zeroToOne = !(permissionsAdapter1Currency == key1.currency0); + Currency inputCurrency; + Currency outputCurrency; + + if (zeroToOne) { + inputCurrency = key1.currency0; + outputCurrency = key1.currency1; + getPermissionedCurrency(key1.currency0).transfer(alice, 2 ether); + } else { + inputCurrency = key1.currency1; + outputCurrency = key1.currency0; + getPermissionedCurrency(key1.currency1).transfer(alice, 2 ether); + } + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key1, zeroToOne, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = plan.finalizeSwap(inputCurrency, outputCurrency, ActionConstants.MSG_SENDER); + + vm.prank(unauthorizedUser); + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(permissionedHooks), + IHooks.beforeSwap.selector, + abi.encodeWithSelector(Unauthorized.selector), + abi.encodeWithSelector(HookCallFailed.selector) + ) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_permission_flag_none_swap_reverts() public { + // Test that NONE permissions prevent swap operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.NONE); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.NONE); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + vm.prank(alice); + vm.expectRevert(); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_router_user_payment_permissioned_token_unauthorized_reverts() public { + // Test that LIQUIDITY_ALLOWED does not allow swap operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, insecureHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + vm.startPrank(alice); + vm.expectRevert(Unauthorized.selector); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + vm.stopPrank(); + } + + function test_permission_flag_liquidity_allowed_swap_reverts() public { + // Test that LIQUIDITY_ALLOWED does not allow swap operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.LIQUIDITY_ALLOWED); + + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + vm.prank(alice); + vm.expectRevert(); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_permission_flag_all_allowed_swap_succeeds() public { + // Test that ALL_ALLOWED allows swap operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.ALL_ALLOWED); + + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_permission_flag_combinations_swap() public { + // Test various combinations of permissions for swaps + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + // Test SWAP_ALLOWED + LIQUIDITY_ALLOWED (should work like ALL_ALLOWED) + MockPermissionedToken(Currency.unwrap(currency0)) + .setAllowlist(alice, (PermissionFlags.SWAP_ALLOWED | PermissionFlags.LIQUIDITY_ALLOWED)); + MockPermissionedToken(Currency.unwrap(currency1)) + .setAllowlist(alice, (PermissionFlags.SWAP_ALLOWED | PermissionFlags.LIQUIDITY_ALLOWED)); + + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_permission_flag_partial_permissions_swap() public { + // Test that having permissions on only one token is not enough for swaps + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.NONE); + + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + vm.prank(alice); + vm.expectRevert(); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_permission_flag_dynamic_changes_swap() public { + // Test that permission changes take effect immediately for swaps + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + // Should succeed initially + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + + // Remove permissions + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.NONE); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.NONE); + + // Should fail on subsequent operations + vm.prank(alice); + vm.expectRevert(); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_permission_flag_edge_cases_swap() public { + // Test edge cases with permission flags for swaps + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + // Test with zero permissions (should be same as NONE) + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlag.wrap(0x0000)); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlag.wrap(0x0000)); + + vm.prank(alice); + vm.expectRevert(); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + + // Test with maximum permissions (should be same as ALL_ALLOWED) + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlag.wrap(0xFFFF)); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlag.wrap(0xFFFF)); + + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_permission_flag_all_pool_types_swap() public { + MockPermissionedToken(Currency.unwrap(getPermissionedCurrency(permissionsAdapter0Currency))) + .setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + MockPermissionedToken(Currency.unwrap(getPermissionedCurrency(permissionsAdapter1Currency))) + .setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + + _test_permission_flag_all_pool_types_swap(key0); + _test_permission_flag_all_pool_types_swap(key1); + _test_permission_flag_all_pool_types_swap(key2); + } + + function _test_permission_flag_all_pool_types_swap(PoolKey memory poolKey) public { + IERC20(Currency.unwrap(getPermissionedCurrency(poolKey.currency0))).transfer(alice, 2 ether); + IERC20(Currency.unwrap(getPermissionedCurrency(poolKey.currency1))).transfer(alice, 2 ether); + + uint256 amountIn = 100; + plan = Planner.init(); + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(poolKey, true, uint128(amountIn), 0, 0, bytes("")); + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + plan = plan.add(Actions.SETTLE, abi.encode(poolKey.currency0, ActionConstants.CONTRACT_BALANCE, false)); + plan = plan.add(Actions.TAKE_ALL, abi.encode(poolKey.currency1, 0)); + bytes memory data = plan.finalizeSwap(poolKey.currency0, poolKey.currency1, ActionConstants.MSG_SENDER); + + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_permission_flag_exact_output_swap() public { + // Test permission flags with exact output swaps + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountOut = 50; + uint256 amountInMaximum = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactOutputSingleParams memory params = IV4Router.ExactOutputSingleParams( + adapterKey, true, uint128(amountOut), uint128(amountInMaximum), 0, bytes("") + ); + + plan = plan.add(Actions.SWAP_EXACT_OUT_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_permission_flag_multi_hop_swap() public { + // Test permission flags with multi-hop swaps + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + Currency[] memory path = new Currency[](2); + path[0] = permissionsAdapter1Currency; + path[1] = permissionsAdapter0Currency; + + // Create path keys for the multi-hop swap + PathKey[] memory pathKeys = new PathKey[](1); + pathKeys[0] = PathKey({ + intermediateCurrency: permissionsAdapter0Currency, + fee: 3000, + tickSpacing: 60, + hooks: permissionedHooks, + hookData: bytes("") + }); + + IV4Router.ExactInputParams memory params = + IV4Router.ExactInputParams(permissionsAdapter1Currency, pathKeys, new uint256[](0), uint128(amountIn), 0); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_router_user_payment_permissioned_token() public { + // Test that SWAP_ALLOWED allows swap operations + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(alice, PermissionFlags.SWAP_ALLOWED); + + IERC20(Currency.unwrap(currency0)).transfer(alice, 2 ether); + IERC20(Currency.unwrap(currency1)).transfer(alice, 2 ether); + + uint256 amountIn = 100; + PoolKey memory adapterKey = + PoolKey(permissionsAdapter1Currency, permissionsAdapter0Currency, 3000, 60, permissionedHooks); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(adapterKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = + plan.finalizeSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, ActionConstants.MSG_SENDER); + + vm.prank(alice); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + /*////////////////////////////////////////////////////////////// + ERC20 -> ERC20 EXACT INPUT + //////////////////////////////////////////////////////////////*/ + + function test_swapExactInputSingle_revertsForAmountOut() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + // min amount out of 1 higher than the actual amount out + IV4Router.ExactInputSingleParams memory params = IV4Router.ExactInputSingleParams( + key0, true, uint128(amountIn), uint128(expectedAmountOut + 1), 0, bytes("") + ); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + bytes memory data = plan.finalizeSwap(key0.currency0, key0.currency1, ActionConstants.MSG_SENDER); + + vm.expectRevert( + abi.encodeWithSelector(IV4Router.V4TooLittleReceived.selector, expectedAmountOut + 1, expectedAmountOut) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_swapExactInputSingle_zeroForOne_takeToMsgSender() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key0, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key0, true, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(key0.currency0, key0.currency1, amountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key0, true, address(manager)); + + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_swapExactInputSingle_zeroForOne_takeToRecipient() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key0, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + + uint256 aliceOutputBalanceBefore = getPermissionedCurrency(key0.currency1).balanceOf(alice); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key0, true, address(manager)); + // swap with alice as the take recipient + _finalizeAndExecuteSwap(key0.currency0, key0.currency1, amountIn, alice); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key0, true, address(manager)); + + uint256 aliceOutputBalanceAfter = getPermissionedCurrency(key0.currency1).balanceOf(alice); + + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + assertEq(aliceOutputBalanceAfter - aliceOutputBalanceBefore, expectedAmountOut); + } + + // This is not a real use-case in isolation, but will be used in the UniversalRouter if a v4 + // swap is before another swap on v2/v3 + function test_swapExactInputSingle_zeroForOne_takeAllToRouter() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key0, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + + // the router holds no funds before + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key0, true, address(manager)); + // swap with the router as the take recipient + _finalizeAndExecuteSwap(key0.currency0, key0.currency1, amountIn, ActionConstants.ADDRESS_THIS); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key0, true, address(manager)); + + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + // This is not a real use-case in isolation, but will be used in the UniversalRouter if a v4 + // swap is before another swap on v2/v3 + function test_swapExactInputSingle_zeroForOne_takeToRouter() public { + uint256 amountIn = 1000; + uint256 expectedAmountOut = 949; + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key0, true, uint128(amountIn), 0, 0, bytes("")); + + Currency inputCurrency = getPermissionedCurrency(key0.currency0); + Currency outputCurrency = getPermissionedCurrency(key0.currency1); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + plan = plan.add(Actions.SETTLE_ALL, abi.encode(key0.currency0, expectedAmountOut * 12 / 10)); + // take the entire open delta to the router's address + plan = plan.add( + Actions.TAKE, abi.encode(key0.currency1, ActionConstants.ADDRESS_THIS, ActionConstants.OPEN_DELTA) + ); + bytes memory data = plan.encode(); + // the router holds no funds before + assertEq(inputCurrency.balanceOf(address(permissionedRouter)), 0); + assertEq(outputCurrency.balanceOf(address(permissionedRouter)), 0); + + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + // the output tokens have been left in the router + assertEq(outputCurrency.balanceOf(address(permissionedRouter)), expectedAmountOut); + assertEq(inputCurrency.balanceOf(address(permissionedRouter)), 0); + } + + function test_swapExactInputSingle_oneForZero() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + bool zeroForOne = false; + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key0, zeroForOne, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key0, zeroForOne, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(key0.currency1, key0.currency0, amountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key0, zeroForOne, address(manager)); + + assertEq(key0.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(key0.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_swapExactInput_revertsForAmountOut() public { + uint256 amountIn = 1000; + uint256 expectedAmountOut = 949; + + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + + IV4Router.ExactInputParams memory params = + _getExactInputParamsWithHook(tokenPath, amountIn, address(permissionedHooks), expectedAmountOut); + params.amountOutMinimum = uint128(expectedAmountOut + 1); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + bytes memory data = plan.finalizeSwap(key0.currency0, key0.currency1, ActionConstants.MSG_SENDER); + + vm.expectRevert( + abi.encodeWithSelector(IV4Router.V4TooLittleReceived.selector, expectedAmountOut + 1, expectedAmountOut) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_swapExactIn_1Hop_zeroForOne() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + + IV4Router.ExactInputParams memory params = + _getExactInputParamsWithHook(tokenPath, amountIn, address(permissionedHooks), expectedAmountOut); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(permissionsAdapter0Currency, permissionsAdapter1Currency, amountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_swapExactIn_1Hop_oneForZero() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(permissionsAdapter0Currency); + + IV4Router.ExactInputParams memory params = + _getExactInputParamsWithHook(tokenPath, amountIn, address(permissionedHooks), expectedAmountOut); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(permissionsAdapter1Currency, permissionsAdapter0Currency, amountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_swapExactIn_2Hops() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 9982; + + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(currency2); + + IV4Router.ExactInputParams memory params = + _getExactInputParamsWithHook(tokenPath, amountIn, address(permissionedHooks), expectedAmountOut); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + + uint256 intermediateBalanceBefore = currency1.balanceOfSelf(); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(permissionsAdapter0Currency, currency2, amountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + // check intermediate token balances + assertEq(intermediateBalanceBefore, currency1.balanceOfSelf()); + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(currency2.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_swapExactIn_3Hops() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 6645; + + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(currency2); + tokenPath.push(currency3); + + // Build path manually: first two hops use permissionedHooks, last hop uses address(0) (key2 is hookless) + PathKey[] memory path = new PathKey[](3); + path[0] = PathKey(permissionsAdapter1Currency, 3000, 60, IHooks(address(permissionedHooks)), bytes("")); + path[1] = PathKey(currency2, 3000, 60, IHooks(address(permissionedHooks)), bytes("")); + path[2] = PathKey(currency3, 3000, 60, IHooks(address(0)), bytes("")); + IV4Router.ExactInputParams memory params = IV4Router.ExactInputParams({ + currencyIn: permissionsAdapter0Currency, + path: path, + minHopPriceX36: new uint256[](0), + amountIn: uint128(amountIn), + amountOutMinimum: uint128(expectedAmountOut) + }); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(permissionsAdapter0Currency, currency3, amountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + // check intermediate tokens werent left in the router + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(currency2.balanceOf(address(permissionedRouter)), 0); + assertEq(currency3.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + /*////////////////////////////////////////////////////////////// + ETH -> ERC20 and ERC20 -> ETH EXACT INPUT + //////////////////////////////////////////////////////////////*/ + + function test_nativeIn_swapExactInputSingle() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(nativeKey, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + + (, uint256 outputBalanceBefore, uint256 ethBalanceBefore) = + getInputAndOutputBalances(nativeKey, true, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(nativeKey.currency0, nativeKey.currency1, amountIn); + (, uint256 outputBalanceAfter, uint256 ethBalanceAfter) = + getInputAndOutputBalances(nativeKey, true, address(manager)); + + assertEq(nativeKey.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(nativeKey.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(ethBalanceBefore - ethBalanceAfter, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_nativeOut_swapExactInputSingle() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + bool zeroForOne = false; + + // native output means we need !zeroForOne + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(nativeKey, zeroForOne, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + + (uint256 inputBalanceBefore,, uint256 ethBalanceBefore) = + getInputAndOutputBalances(nativeKey, zeroForOne, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(nativeKey.currency1, nativeKey.currency0, amountIn); + (uint256 inputBalanceAfter,, uint256 ethBalanceAfter) = + getInputAndOutputBalances(nativeKey, zeroForOne, address(manager)); + + assertEq(nativeKey.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(nativeKey.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(ethBalanceAfter - ethBalanceBefore, expectedAmountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + } + + function test_nativeIn_swapExactIn_1Hop() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + + tokenPath.push(CurrencyLibrary.ADDRESS_ZERO); + tokenPath.push(nativeKey.currency1); + + IV4Router.ExactInputParams memory params = + _getExactInputParamsWithHook(tokenPath, amountIn, address(permissionedHooks)); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + + (, uint256 outputBalanceBefore, uint256 ethBalanceBefore) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(CurrencyLibrary.ADDRESS_ZERO, nativeKey.currency1, amountIn); + (, uint256 outputBalanceAfter, uint256 ethBalanceAfter) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(nativeKey.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(nativeKey.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(ethBalanceBefore - ethBalanceAfter, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_nativeOut_swapExactIn_1Hop() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 19992; + + tokenPath.push(nativeKey.currency1); + tokenPath.push(CurrencyLibrary.ADDRESS_ZERO); + + IV4Router.ExactInputParams memory params = + _getExactInputParamsWithHook(tokenPath, amountIn, address(permissionedHooks)); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + + (uint256 inputBalanceBefore,, uint256 ethBalanceBefore) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(nativeKey.currency1, CurrencyLibrary.ADDRESS_ZERO, amountIn); + (uint256 inputBalanceAfter,, uint256 ethBalanceAfter) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(nativeKey.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(nativeKey.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(ethBalanceAfter - ethBalanceBefore, expectedAmountOut); + } + + function test_nativeIn_swapExactIn_2Hops() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 9982; + + // the initialized nativeKey is (native, currency0) + tokenPath.push(CurrencyLibrary.ADDRESS_ZERO); + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + + IV4Router.ExactInputParams memory params = + _getExactInputParamsWithHook(tokenPath, amountIn, address(permissionedHooks)); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + + uint256 intermediateBalanceBefore = currency0.balanceOfSelf(); + (, uint256 outputBalanceBefore, uint256 ethBalanceBefore) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(CurrencyLibrary.ADDRESS_ZERO, permissionsAdapter1Currency, amountIn); + (, uint256 outputBalanceAfter, uint256 ethBalanceAfter) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + // check intermediate token balances + assertEq(intermediateBalanceBefore, currency0.balanceOfSelf()); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(nativeKey.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(nativeKey.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(ethBalanceBefore - ethBalanceAfter, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + function test_nativeOut_swapExactIn_2Hops() public { + uint256 amountIn = 1 ether; + uint256 expectedAmountOut = 9982; + + // the initialized nativeKey is (native, currency0) + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(CurrencyLibrary.ADDRESS_ZERO); + + IV4Router.ExactInputParams memory params = + _getExactInputParamsWithHook(tokenPath, amountIn, address(permissionedHooks)); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + + uint256 intermediateBalanceBefore = currency0.balanceOfSelf(); + + (uint256 inputBalanceBefore,, uint256 ethBalanceBefore) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(permissionsAdapter1Currency, CurrencyLibrary.ADDRESS_ZERO, amountIn); + (uint256 inputBalanceAfter,, uint256 ethBalanceAfter) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + // check intermediate token balances + assertEq(intermediateBalanceBefore, currency0.balanceOfSelf()); + assertEq(userInputBalanceBefore - userInputBalanceAfter, amountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, expectedAmountOut); + assertEq(nativeKey.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(nativeKey.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(ethBalanceAfter - ethBalanceBefore, expectedAmountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + } + + function test_swap_nativeIn_settleRouterBalance_swapOpenDelta() public { + uint256 amountIn = 1000; + uint256 expectedAmountOut = 949; + bool zeroForOne = true; + + vm.deal(address(permissionedRouter), amountIn); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(nativeKey, zeroForOne, ActionConstants.OPEN_DELTA, 0, 0, bytes("")); + + plan = plan.add(Actions.SETTLE, abi.encode(nativeKey.currency0, ActionConstants.CONTRACT_BALANCE, false)); + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + plan = plan.add(Actions.TAKE_ALL, abi.encode(nativeKey.currency1, MIN_TAKE_AMOUNT)); + + bytes memory data = plan.encode(); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore, uint256 ethBalanceBefore) = + getInputAndOutputBalances(nativeKey, zeroForOne, address(manager)); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter, uint256 ethBalanceAfter) = + getInputAndOutputBalances(nativeKey, zeroForOne, address(manager)); + + // caller didnt pay, router paid, caller received the output + assertEq(ethBalanceBefore, ethBalanceAfter); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedAmountOut); + } + + /*////////////////////////////////////////////////////////////// + ERC20 -> ERC20 EXACT OUTPUT + //////////////////////////////////////////////////////////////*/ + + function test_swapExactOutputSingle_revertsForAmountIn() public { + uint256 amountOut = 1 ether; + uint256 expectedAmountIn = 369070324193623892281288; + + IV4Router.ExactOutputSingleParams memory params = IV4Router.ExactOutputSingleParams( + key0, true, uint128(amountOut), uint128(expectedAmountIn - 1), 0, bytes("") + ); + + plan = plan.add(Actions.SWAP_EXACT_OUT_SINGLE, abi.encode(params)); + bytes memory data = plan.finalizeSwap(key0.currency0, key0.currency1, ActionConstants.MSG_SENDER); + + vm.expectRevert( + abi.encodeWithSelector( + IV4Router.V4TooMuchRequested.selector, expectedAmountIn - 1, 369070324193623892281288 + ) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_swapExactOutputSingle_zeroForOne() public { + uint256 amountOut = 19992; + uint256 expectedAmountIn = 434604409; + bool zeroForOne = true; + + IV4Router.ExactOutputSingleParams memory params = IV4Router.ExactOutputSingleParams( + key0, zeroForOne, uint128(amountOut), uint128(expectedAmountIn + 1), 0, bytes("") + ); + + plan = plan.add(Actions.SWAP_EXACT_OUT_SINGLE, abi.encode(params)); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key0, zeroForOne, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(key0.currency0, key0.currency1, expectedAmountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key0, zeroForOne, address(manager)); + + assertEq(key0.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(key0.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(outputBalanceBefore - outputBalanceAfter, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + function test_swapExactOutputSingle_oneForZero() public { + uint256 amountOut = 19992; + uint256 expectedAmountIn = 433302557; + bool zeroForOne = false; + + IV4Router.ExactOutputSingleParams memory params = IV4Router.ExactOutputSingleParams( + key0, zeroForOne, uint128(amountOut), uint128(expectedAmountIn + 1), 0, bytes("") + ); + + plan = plan.add(Actions.SWAP_EXACT_OUT_SINGLE, abi.encode(params)); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key0, zeroForOne, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(key0.currency1, key0.currency0, expectedAmountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key0, zeroForOne, address(manager)); + + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(outputBalanceBefore - outputBalanceAfter, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + function test_swapExactOut_revertsForAmountIn() public { + uint256 amountOut = 1 ether; + uint256 expectedAmountIn = 369070324262815812743748; + + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + + IV4Router.ExactOutputParams memory params = + _getExactOutputParamsWithHook(tokenPath, amountOut, address(permissionedHooks), expectedAmountIn); + params.amountInMaximum = uint128(expectedAmountIn - 1); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + bytes memory data = plan.finalizeSwap(key0.currency0, key0.currency1, ActionConstants.MSG_SENDER); + + vm.expectRevert( + abi.encodeWithSelector(IV4Router.V4TooMuchRequested.selector, expectedAmountIn - 1, expectedAmountIn) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_swapExactOut_1Hop_zeroForOne() public { + uint256 amountOut = 19992; + uint256 expectedAmountIn = 434604409; + + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(permissionsAdapter0Currency); + + IV4Router.ExactOutputParams memory params = + _getExactOutputParamsWithHook(tokenPath, amountOut, address(permissionedHooks), expectedAmountIn); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(key0.currency0, key0.currency1, expectedAmountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(outputBalanceBefore - outputBalanceAfter, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + function test_swapExactOut_1Hop_oneForZero() public { + uint256 amountOut = 19992; + uint256 expectedAmountIn = 433302557; + + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + + IV4Router.ExactOutputParams memory params = + _getExactOutputParamsWithHook(tokenPath, amountOut, address(permissionedHooks), expectedAmountIn); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(permissionsAdapter0Currency, key0.currency0, expectedAmountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(outputBalanceBefore - outputBalanceAfter, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + function test_swapExactOut_2Hops() public { + uint256 amountOut = 100; + uint256 expectedAmountIn = 104; + + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(currency2); + + IV4Router.ExactOutputParams memory params = + _getExactOutputParamsWithHook(tokenPath, amountOut, address(permissionedHooks), expectedAmountIn); + + uint256 intermediateBalanceBefore = currency1.balanceOfSelf(); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(permissionsAdapter0Currency, currency2, expectedAmountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(intermediateBalanceBefore, currency1.balanceOfSelf()); + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(currency2.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(outputBalanceBefore - outputBalanceAfter, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + function test_swapExactOut_3Hops() public { + uint256 amountOut = 100; + uint256 expectedAmountIn = 106; + + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(currency2); + tokenPath.push(currency3); + + // Build path following _getExactOutputParamsWithHook pattern: + // path[2] = currency2 (hop: currency3→currency2, hookless pool) + // path[1] = adapter1 (hop: currency2→adapter1, permissioned) + // path[0] = adapter0 (hop: adapter1→adapter0, permissioned) + PathKey[] memory path = new PathKey[](3); + path[0] = PathKey(permissionsAdapter0Currency, 3000, 60, IHooks(address(permissionedHooks)), bytes("")); + path[1] = PathKey(permissionsAdapter1Currency, 3000, 60, IHooks(address(permissionedHooks)), bytes("")); + path[2] = PathKey(currency2, 3000, 60, IHooks(address(0)), bytes("")); + IV4Router.ExactOutputParams memory params = IV4Router.ExactOutputParams({ + currencyOut: currency3, + path: path, + minHopPriceX36: new uint256[](0), + amountOut: uint128(amountOut), + amountInMaximum: uint128(expectedAmountIn) + }); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(key0.currency1, currency3, expectedAmountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(currency2.balanceOf(address(permissionedRouter)), 0); + assertEq(currency3.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(outputBalanceBefore - outputBalanceAfter, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + function test_swapExactOut_3Hops_permissioned_middle() public { + uint256 amountOut = 100; + uint256 expectedAmountIn = 106; + + tokenPath.push(currency4); + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(currency2); + + IV4Router.ExactOutputParams memory params = + _getExactOutputParamsWithHook(tokenPath, amountOut, address(permissionedHooks), expectedAmountIn); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(currency4, currency2, expectedAmountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(currency4.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(currency2.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(outputBalanceBefore - outputBalanceAfter, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + function test_swapExactOut_3Hops_mmiddle_unauthorized_reverts() public { + uint256 amountOut = 100; + uint256 expectedAmountIn = 106; + + currency4.transfer(unauthorizedUser, 1000); + + tokenPath.push(currency4); + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(currency2); + + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(unauthorizedUser, PermissionFlags.NONE); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(unauthorizedUser, PermissionFlags.NONE); + + IV4Router.ExactOutputParams memory params = + _getExactOutputParamsWithHook(tokenPath, amountOut, address(permissionedHooks), expectedAmountIn); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + + bytes memory data = plan.finalizeSwap(currency4, currency2, ActionConstants.MSG_SENDER); + + vm.prank(unauthorizedUser); + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(permissionedHooks), + IHooks.beforeSwap.selector, + abi.encodeWithSelector(Unauthorized.selector), + abi.encodeWithSelector(HookCallFailed.selector) + ) + ); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_swapExactOut_native_3Hops_permissioned_middle() public { + uint256 amountOut = 100; + uint256 expectedAmountIn = 106; + + tokenPath.push(nativeKey.currency0); + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(currency2); + + IV4Router.ExactOutputParams memory params = + _getExactOutputParamsWithHook(tokenPath, amountOut, address(permissionedHooks), expectedAmountIn); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(nativeKey.currency0, currency2, expectedAmountIn); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(nativeKey.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter0.balanceOf(address(permissionedRouter)), 0); + assertEq(permissionsAdapter1.balanceOf(address(permissionedRouter)), 0); + assertEq(currency2.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(outputBalanceBefore - outputBalanceAfter, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + function test_swapExactOut_native_3Hops_mmiddle_unauthorized_reverts() public { + nativeKey.currency0.transfer(unauthorizedUser, 1000); + + uint256 amountOut = 100; + uint256 expectedAmountIn = 106; + + tokenPath.push(nativeKey.currency0); + tokenPath.push(permissionsAdapter0Currency); + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(currency2); + + MockPermissionedToken(Currency.unwrap(currency0)).setAllowlist(unauthorizedUser, PermissionFlags.NONE); + MockPermissionedToken(Currency.unwrap(currency1)).setAllowlist(unauthorizedUser, PermissionFlags.NONE); + + IV4Router.ExactOutputParams memory params = + _getExactOutputParamsWithHook(tokenPath, amountOut, address(permissionedHooks), expectedAmountIn); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + + bytes memory data = plan.finalizeSwap(nativeKey.currency0, currency2, ActionConstants.MSG_SENDER); + + vm.prank(unauthorizedUser); + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(permissionedHooks), + IHooks.beforeSwap.selector, + abi.encodeWithSelector(Unauthorized.selector), + abi.encodeWithSelector(HookCallFailed.selector) + ) + ); + permissionedRouter.execute{value: (nativeKey.currency0.isAddressZero()) ? expectedAmountIn : 0}( + COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max + ); + } + + function test_swapExactOutputSingle_swapOpenDelta() public { + uint256 expectedAmountIn = 946; + uint256 expectedOutput = 900; + + IV4Router.ExactOutputSingleParams memory params = IV4Router.ExactOutputSingleParams( + key0, true, ActionConstants.OPEN_DELTA, uint128(expectedAmountIn), 0, bytes("") + ); + + plan = plan.add(Actions.TAKE, abi.encode(key0.currency1, ActionConstants.ADDRESS_THIS, expectedOutput)); + plan = plan.add(Actions.SWAP_EXACT_OUT_SINGLE, abi.encode(params)); + plan = plan.add(Actions.SETTLE, abi.encode(key0.currency0, ActionConstants.OPEN_DELTA, true)); + + bytes memory data = plan.encode(); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key0, true, address(manager)); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key0, true, address(manager)); + + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedOutput); + } + + function test_swapExactOut_swapOpenDelta() public { + uint256 expectedAmountIn = 1057; + uint256 expectedOutput = 1000; + + tokenPath.push(permissionsAdapter1Currency); + tokenPath.push(permissionsAdapter0Currency); + + IV4Router.ExactOutputParams memory params = _getExactOutputParamsWithHook( + tokenPath, ActionConstants.OPEN_DELTA, address(permissionedHooks), expectedAmountIn + ); + + plan = plan.add(Actions.TAKE, abi.encode(key0.currency1, ActionConstants.ADDRESS_THIS, expectedOutput)); + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + plan = plan.add(Actions.SETTLE, abi.encode(key0.currency0, ActionConstants.OPEN_DELTA, true)); + + bytes memory data = plan.encode(); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + assertEq(outputBalanceBefore - outputBalanceAfter, expectedOutput); + } + + /*////////////////////////////////////////////////////////////// + ETH -> ERC20 and ERC20 -> ETH EXACT OUTPUT + //////////////////////////////////////////////////////////////*/ + + function test_nativeOut_swapExactOutputSingle() public { + uint256 amountOut = 19992; + uint256 expectedAmountIn = 433302557; + bool zeroForOne = false; + + IV4Router.ExactOutputSingleParams memory params = IV4Router.ExactOutputSingleParams( + nativeKey, zeroForOne, uint128(amountOut), uint128(expectedAmountIn + 1), 0, bytes("") + ); + + plan = plan.add(Actions.SWAP_EXACT_OUT_SINGLE, abi.encode(params)); + + (uint256 inputBalanceBefore,, uint256 ethBalanceBefore) = + getInputAndOutputBalances(nativeKey, zeroForOne, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(nativeKey.currency1, nativeKey.currency0, expectedAmountIn); + (uint256 inputBalanceAfter,, uint256 ethBalanceAfter) = + getInputAndOutputBalances(nativeKey, zeroForOne, address(manager)); + + assertEq(nativeKey.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(nativeKey.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(ethBalanceAfter - ethBalanceBefore, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + function test_nativeOut_swapExactOut_1Hop() public { + uint256 amountOut = 19992; + uint256 expectedAmountIn = 433302557; + + tokenPath.push(nativeKey.currency1); + tokenPath.push(CurrencyLibrary.ADDRESS_ZERO); + + IV4Router.ExactOutputParams memory params = + _getExactOutputParamsWithHook(tokenPath, amountOut, address(permissionedHooks), expectedAmountIn); + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + (uint256 inputBalanceBefore,, uint256 ethBalanceBefore) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + ( + uint256 userInputBalanceBefore, + uint256 userOutputBalanceBefore, + uint256 userInputBalanceAfter, + uint256 userOutputBalanceAfter + ) = _finalizeAndExecuteSwap(nativeKey.currency1, CurrencyLibrary.ADDRESS_ZERO, expectedAmountIn); + (uint256 inputBalanceAfter,, uint256 ethBalanceAfter) = + getInputAndOutputBalancesPath(tokenPath, address(manager)); + + assertEq(nativeKey.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(nativeKey.currency1.balanceOf(address(permissionedRouter)), 0); + assertEq(userInputBalanceBefore - userInputBalanceAfter, expectedAmountIn); + assertEq(userOutputBalanceAfter - userOutputBalanceBefore, amountOut); + assertEq(ethBalanceAfter - ethBalanceBefore, amountOut); + assertEq(inputBalanceAfter - inputBalanceBefore, expectedAmountIn); + } + + /*////////////////////////////////////////////////////////////// + ROUTER SELF-PAYMENT COVERAGE TEST + //////////////////////////////////////////////////////////////*/ + + function test_router_self_payment() public { + uint256 amountIn = 100000; + + IERC20(Currency.unwrap(key2.currency0)).transfer(address(permissionedRouter), amountIn); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key2, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SETTLE, abi.encode(key2.currency0, ActionConstants.CONTRACT_BALANCE, false)); + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + plan = plan.add(Actions.TAKE_ALL, abi.encode(key2.currency1, 0)); + + bytes memory data = plan.encode(); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key2, true, address(manager)); + + // Execute the swap - this should trigger the _pay function with payer == address(this) and permissionedToken == address(0) + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key2, true, address(manager)); + + // Verify the router's balance was used (it should be 0 after the swap) + assertEq(key2.currency0.balanceOf(address(permissionedRouter)), 0); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertGt(outputBalanceBefore - outputBalanceAfter, 0); + } + + function test_router_self_payment_permissioned_token() public { + uint256 amountIn = 100000; + + IERC20(Currency.unwrap(getPermissionedCurrency(key0.currency0))).transfer(address(permissionedRouter), amountIn); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key0, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SETTLE, abi.encode((key0.currency0), ActionConstants.CONTRACT_BALANCE, false)); + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + plan = plan.add(Actions.TAKE_ALL, abi.encode((key0.currency1), 0)); + + bytes memory data = plan.encode(); + + (uint256 inputBalanceBefore, uint256 outputBalanceBefore,) = + getInputAndOutputBalances(key0, true, address(manager)); + + // Execute the swap - this should trigger the _pay function with payer == address(this) and permissionedToken != address(0) + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + + (uint256 inputBalanceAfter, uint256 outputBalanceAfter,) = + getInputAndOutputBalances(key0, true, address(manager)); + + // Verify the router's balance was used (it should be 0 after the swap) + assertEq(getPermissionedCurrency(key0.currency0).balanceOf(address(permissionedRouter)), 0); + assertEq(inputBalanceAfter - inputBalanceBefore, amountIn); + assertGt(outputBalanceBefore - outputBalanceAfter, 0); + } + + function test_router_self_payment_permissioned_token_unauthorized_reverts() public { + uint256 amountIn = 100000; + + MockPermissionedToken(Currency.unwrap(getPermissionedCurrency(key0.currency0))) + .setAllowlist(unauthorizedUser, PermissionFlags.ALL_ALLOWED); + IERC20(Currency.unwrap(getPermissionedCurrency(key0.currency0))).transfer(address(unauthorizedUser), amountIn); + + vm.prank(unauthorizedUser); + IERC20(Currency.unwrap(getPermissionedCurrency(key0.currency0))).transfer(address(permissionedRouter), amountIn); + + MockPermissionedToken(Currency.unwrap(getPermissionedCurrency(key0.currency0))) + .setAllowlist(unauthorizedUser, PermissionFlags.NONE); + + IV4Router.ExactInputSingleParams memory params = + IV4Router.ExactInputSingleParams(key0, true, uint128(amountIn), 0, 0, bytes("")); + + plan = plan.add(Actions.SETTLE, abi.encode((key0.currency0), ActionConstants.CONTRACT_BALANCE, false)); + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + plan = plan.add(Actions.TAKE_ALL, abi.encode((key0.currency1), 0)); + + bytes memory data = plan.encode(); + + vm.prank(unauthorizedUser); + vm.expectRevert(Unauthorized.selector); + permissionedRouter.execute(COMMAND_V4_SWAP, toBytesArray(data), type(uint256).max); + } + + function test_command_permit2_permit() public { + IAllowanceTransfer.PermitSingle memory permitSingle = IAllowanceTransfer.PermitSingle({ + details: IAllowanceTransfer.PermitDetails({ + token: Currency.unwrap(currency0), amount: 1e18, expiration: uint48(block.timestamp + 3600), nonce: 0 + }), + spender: address(permissionedRouter), + sigDeadline: block.timestamp + 3600 + }); + + uint256 testKey = uint256(0x22); + address testSigner = vm.addr(testKey); + + bytes32 digest = generatePermitDigest(permitSingle); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(testKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + bytes memory data = abi.encode(permitSingle, signature); + + vm.prank(testSigner); + permissionedRouter.execute(COMMAND_PERMIT2_PERMIT, toBytesArray(data), type(uint256).max); + } + + function test_invalid_command_reverts() public { + bytes memory data = hex"1234567890"; + vm.expectRevert(abi.encodeWithSelector(InvalidCommandType.selector, uint8(0x07))); + permissionedRouter.execute(hex"07", toBytesArray(data), type(uint256).max); + } + + function test_failed_call_reverts() public { + IAllowanceTransfer.PermitSingle memory permitSingle = IAllowanceTransfer.PermitSingle({ + details: IAllowanceTransfer.PermitDetails({ + token: Currency.unwrap(currency0), amount: 1e18, expiration: uint48(block.timestamp + 3600), nonce: 0 + }), + spender: address(permissionedRouter), + sigDeadline: block.timestamp + 3600 + }); + uint256 testKey = uint256(0x22); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(testKey, hex"aa"); + bytes memory signature = abi.encodePacked(r, s, v); + bytes memory data = abi.encode(permitSingle, signature); + + vm.expectRevert(abi.encodeWithSelector(ExecutionFailed.selector, 0, hex"")); + permissionedRouter.execute(COMMAND_PERMIT2_PERMIT, toBytesArray(data), type(uint256).max); + } + + function test_slice_out_of_bounds_reverts() public { + bytes memory data = hex"1234567890"; + vm.expectRevert(SliceOutOfBounds.selector); + permissionedRouter.execute(COMMAND_PERMIT2_PERMIT, toBytesArray(data), type(uint256).max); + } + + function test_hooks() public { + SwapParams memory swapParams = SwapParams({zeroForOne: true, amountSpecified: 100000, sqrtPriceLimitX96: 0}); + ModifyLiquidityParams memory modifyLiquidityParams = + ModifyLiquidityParams({tickLower: 0, tickUpper: 0, liquidityDelta: 0, salt: bytes32(0)}); + BalanceDelta balanceDelta = BalanceDelta.wrap(0); + + // Use manager to avoid NotPoolManager errors for hooks with OnlyPoolManager modifier + vm.startPrank(address(manager)); + vm.expectRevert(HookNotImplemented.selector); + permissionedHooks.afterSwap(address(this), key0, swapParams, balanceDelta, bytes("")); + vm.expectRevert(HookNotImplemented.selector); + permissionedHooks.afterInitialize(address(this), key0, 0, 0); + vm.expectRevert(HookNotImplemented.selector); + permissionedHooks.beforeRemoveLiquidity(address(this), key0, modifyLiquidityParams, bytes("")); + vm.expectRevert(HookNotImplemented.selector); + permissionedHooks.afterRemoveLiquidity( + address(this), key0, modifyLiquidityParams, balanceDelta, balanceDelta, bytes("") + ); + vm.expectRevert(HookNotImplemented.selector); + permissionedHooks.afterAddLiquidity( + address(this), key0, modifyLiquidityParams, balanceDelta, balanceDelta, bytes("") + ); + vm.expectRevert(HookNotImplemented.selector); + permissionedHooks.beforeDonate(address(this), key0, 0, 0, bytes("")); + vm.expectRevert(HookNotImplemented.selector); + permissionedHooks.afterDonate(address(this), key0, 0, 0, bytes("")); + } + + /*////////////////////////////////////////////////////////////// + RECEIVE FALLBACK TESTS + //////////////////////////////////////////////////////////////*/ + + error InvalidEthSender(); + + function test_receive_fallback_reverts() public { + (bool success, bytes memory data) = address(permissionedRouter).call{value: 1 ether}(""); + + assertEq(success, false); + assertEq(bytes4(data), InvalidEthSender.selector); + } + + function test_receive_fallback_from_posm() public { + vm.deal(address(manager), 1 ether); + + vm.prank(address(manager)); + (bool success,) = address(permissionedRouter).call{value: 1 ether}(""); + assertEq(success, true); + } + + function test_receive_fallback_from_weth() public { + vm.deal(address(permissionedRouter), 1 ether); + + vm.startPrank(address(permissionedRouter)); + weth9.deposit{value: 1 ether}(); + weth9.approve(address(permissionedRouter), 1 ether); + weth9.withdraw(1 ether); + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + POOL INITIALIZATION TESTS + //////////////////////////////////////////////////////////////*/ + + function test_initialize_reverts_no_verified_adapter() public { + // Two non-permissioned tokens — neither is a verified adapter + (Currency c0, Currency c1) = currency2 < currency3 ? (currency2, currency3) : (currency3, currency2); + PoolKey memory unverifiedKey = PoolKey(c0, c1, 3000, 60, permissionedHooks); + vm.expectRevert(); + manager.initialize(unverifiedKey, SQRT_PRICE_1_1); + } + + function test_initialize_succeeds_one_verified_adapter() public { + Currency adapterCurrency = Currency.wrap(address(permissionsAdapter1)); + (Currency c0, Currency c1) = + adapterCurrency < currency3 ? (adapterCurrency, currency3) : (currency3, adapterCurrency); + PoolKey memory oneAdapterKey = PoolKey(c0, c1, 500, 10, permissionedHooks); + manager.initialize(oneAdapterKey, SQRT_PRICE_1_1); + } + + function test_initialize_succeeds_two_verified_adapters() public { + Currency a0 = Currency.wrap(address(permissionsAdapter0)); + Currency a1 = Currency.wrap(address(permissionsAdapter1)); + (Currency c0, Currency c1) = a0 < a1 ? (a0, a1) : (a1, a0); + PoolKey memory twoAdapterKey = PoolKey(c0, c1, 500, 10, permissionedHooks); + manager.initialize(twoAdapterKey, SQRT_PRICE_1_1); + } + + function test_initialize_reverts_unverified_adapter() public { + // Create an adapter for a permissioned token but do NOT verify it — the M-03 attack path + // currency0 is a MockPermissionedToken (first 2 tokens in setup are permissioned) + IERC20 token = IERC20(Currency.unwrap(currency0)); + MockAllowlistChecker checker = new MockAllowlistChecker(MockPermissionedToken(address(token))); + address unverifiedAdapter = permissionsAdapterFactory.createPermissionsAdapter(token, address(this), checker); + // Do NOT verify — skip transferring 1 wei and calling verifyPermissionsAdapter + + Currency adapterCurrency = Currency.wrap(unverifiedAdapter); + (Currency c0, Currency c1) = + adapterCurrency < currency4 ? (adapterCurrency, currency4) : (currency4, adapterCurrency); + PoolKey memory attackKey = PoolKey(c0, c1, 3000, 60, permissionedHooks); + vm.expectRevert(); + manager.initialize(attackKey, SQRT_PRICE_1_1); + } + + receive() external payable {} + fallback() external {} +} diff --git a/test/hooks/permissionedPools/PermissionsAdapter.t.sol b/test/hooks/permissionedPools/PermissionsAdapter.t.sol new file mode 100644 index 000000000..96b72c0be --- /dev/null +++ b/test/hooks/permissionedPools/PermissionsAdapter.t.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import { + IPermissionsAdapter, + IAllowlistChecker +} from "../../../src/hooks/permissionedPools/interfaces/IPermissionsAdapter.sol"; +import {IERC20, IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {PermissionedPoolsBase, MockAllowlistChecker} from "./PermissionedPoolsBase.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {PermissionFlags, PermissionFlag} from "../../../src/hooks/permissionedPools/libraries/PermissionFlags.sol"; + +contract PermissionsAdapterTest is PermissionedPoolsBase { + IPermissionsAdapter public permissionsAdapter; + address public mockPoolManager; + address public owner; + + function setUp() public override { + super.setUp(); + mockPoolManager = makeAddr("mockPoolManager"); + owner = makeAddr("owner"); + bytes memory args = abi.encode(permissionedToken, mockPoolManager, owner, allowlistChecker); + bytes memory initcode = abi.encodePacked(vm.getCode("PermissionsAdapter.sol:PermissionsAdapter"), args); + assembly { + sstore(permissionsAdapter.slot, create(0, add(initcode, 0x20), mload(initcode))) + } + permissionedToken.setTokenAllowlist(address(permissionsAdapter), true); + vm.prank(owner); + permissionsAdapter.updateAllowedWrapper(address(this), true); + } + + function test_InitialState() public view { + assertEq(IERC20Metadata(address(permissionsAdapter)).name(), "Uniswap v4 MockToken"); + assertEq(IERC20Metadata(address(permissionsAdapter)).symbol(), "v4MT"); + assertEq(IERC20Metadata(address(permissionsAdapter)).decimals(), permissionedToken.decimals()); + assertEq(permissionsAdapter.totalSupply(), 0); + assertEq(permissionsAdapter.balanceOf(mockPoolManager), 0); + assertEq(address(permissionsAdapter.allowListChecker()), address(allowlistChecker)); + assertEq(permissionsAdapter.POOL_MANAGER(), mockPoolManager); + assertEq(address(permissionsAdapter.PERMISSIONED_TOKEN()), address(permissionedToken)); + } + + function testRevert_WhenNotOwner(address account) public { + vm.assume(account != owner); + vm.startPrank(account); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, account)); + permissionsAdapter.updateAllowedWrapper(account, true); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, account)); + permissionsAdapter.updateAllowListChecker(allowlistChecker); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, account)); + permissionsAdapter.updateSwappingEnabled(true); + vm.stopPrank(); + } + + function testRevert_WhenNotAllowedWrapper(address wrapper) public { + vm.assume(wrapper != address(this)); + assertFalse(permissionsAdapter.allowedWrappers(wrapper)); + vm.prank(wrapper); + vm.expectRevert(abi.encodeWithSelector(IPermissionsAdapter.UnauthorizedWrapper.selector, wrapper)); + permissionsAdapter.wrapToPoolManager(100); + } + + function testRevert_WhenInsufficientBalance(uint256 amount, uint256 transferAmount) public { + vm.assume(amount != 0); + transferAmount = bound(amount, 0, amount - 1); + permissionedToken.mint(address(permissionsAdapter), transferAmount); + vm.expectRevert( + abi.encodeWithSelector(IPermissionsAdapter.InsufficientBalance.selector, amount, transferAmount) + ); + permissionsAdapter.wrapToPoolManager(amount); + } + + function test_WrapToPoolManager(uint256 amount, uint256 actualAmount) public { + actualAmount = bound(amount, amount, type(uint256).max); + permissionedToken.mint(address(permissionsAdapter), actualAmount); + vm.expectEmit(true, true, true, true); + emit IERC20.Transfer(address(0), mockPoolManager, amount); + permissionsAdapter.wrapToPoolManager(amount); + assertEq(permissionsAdapter.balanceOf(mockPoolManager), amount); + assertEq(permissionedToken.balanceOf(mockPoolManager), actualAmount - amount); + } + + function test_UpdateAllowedWrapper(address wrapper, bool allowed) public { + vm.assume(wrapper != address(this)); + assertFalse(permissionsAdapter.allowedWrappers(wrapper)); + vm.prank(owner); + vm.expectEmit(true, true, true, true); + emit IPermissionsAdapter.AllowedWrapperUpdated(wrapper, allowed); + permissionsAdapter.updateAllowedWrapper(wrapper, allowed); + assertEq(permissionsAdapter.allowedWrappers(wrapper), allowed); + } + + function testRevert_WhenNotSupportedInterfaceEOA(IAllowlistChecker newAllowListCheckerEOA) public { + vm.assume(newAllowListCheckerEOA != allowlistChecker); + vm.prank(owner); + vm.expectRevert(); // expect revert without data + permissionsAdapter.updateAllowListChecker(newAllowListCheckerEOA); + } + + function testRevert_WhenNotSupportedInterfaceContract() public { + IAllowlistChecker newAllowListCheckerContract = new ImproperAllowlistChecker(); + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector(IPermissionsAdapter.InvalidAllowListChecker.selector, newAllowListCheckerContract) + ); + permissionsAdapter.updateAllowListChecker(newAllowListCheckerContract); + } + + function test_UpdateAllowListChecker() public { + IAllowlistChecker newAllowListChecker = new MockAllowlistChecker(permissionedToken); + vm.prank(owner); + vm.expectEmit(true, true, true, true); + emit IPermissionsAdapter.AllowListCheckerUpdated(newAllowListChecker); + permissionsAdapter.updateAllowListChecker(newAllowListChecker); + assertEq(address(permissionsAdapter.allowListChecker()), address(newAllowListChecker)); + } + + function test_UpdateSwappingEnabled(bool enabled) public { + vm.prank(owner); + vm.expectEmit(true, true, true, true); + emit IPermissionsAdapter.SwappingEnabledUpdated(enabled); + permissionsAdapter.updateSwappingEnabled(enabled); + assertEq(permissionsAdapter.swappingEnabled(), enabled); + } + + function testRevert_WhenInvalidTransfer(address from, address to) public { + vm.assume(from != address(0) && from != mockPoolManager); + vm.assume(to != address(0) && to != mockPoolManager); + vm.prank(from); + vm.expectRevert(abi.encodeWithSelector(IPermissionsAdapter.InvalidTransfer.selector, from, to)); + permissionsAdapter.transfer(to, 0); + } + + function test_UnwrapOnPoolManagerTransfer(uint256 mintAmount, uint256 transferAmount, address recipient) public { + vm.assume(recipient != address(0) && recipient != mockPoolManager && recipient != address(permissionsAdapter)); + assertEq(permissionedToken.balanceOf(recipient), 0); + permissionedToken.setTokenAllowlist(recipient, true); + transferAmount = bound(transferAmount, 0, mintAmount); + permissionedToken.mint(address(permissionsAdapter), mintAmount); + permissionsAdapter.wrapToPoolManager(mintAmount); + vm.prank(mockPoolManager); + vm.expectEmit(true, true, true, true); + emit IERC20.Transfer(mockPoolManager, recipient, transferAmount); + vm.expectEmit(true, true, true, true); + emit IERC20.Transfer(recipient, address(0), transferAmount); + permissionsAdapter.transfer(recipient, transferAmount); + assertEq(permissionsAdapter.balanceOf(mockPoolManager), mintAmount - transferAmount); + assertEq(permissionedToken.balanceOf(recipient), transferAmount); + } +} + +contract ImproperAllowlistChecker is IAllowlistChecker { + function checkAllowlist(address) public pure returns (PermissionFlag) { + return PermissionFlags.ALL_ALLOWED; + } + + function supportsInterface(bytes4) external pure returns (bool) { + return false; + } +} diff --git a/test/hooks/permissionedPools/PermissionsAdapterFactory.t.sol b/test/hooks/permissionedPools/PermissionsAdapterFactory.t.sol new file mode 100644 index 000000000..378fb1075 --- /dev/null +++ b/test/hooks/permissionedPools/PermissionsAdapterFactory.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import { + IPermissionsAdapter, + IAllowlistChecker +} from "../../../src/hooks/permissionedPools/interfaces/IPermissionsAdapter.sol"; +import { + IPermissionsAdapterFactory +} from "../../../src/hooks/permissionedPools/interfaces/IPermissionsAdapterFactory.sol"; +import {IERC20, IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {PermissionedPoolsBase, MockAllowlistChecker} from "./PermissionedPoolsBase.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {PermissionFlags} from "../../../src/hooks/permissionedPools/libraries/PermissionFlags.sol"; + +contract PermissionsAdapterFactoryTest is PermissionedPoolsBase { + IPermissionsAdapterFactory public permissionsAdapterFactory; + address public mockPoolManager; + + function setUp() public override { + super.setUp(); + mockPoolManager = makeAddr("mockPoolManager"); + bytes memory args = abi.encode(mockPoolManager); + bytes memory initcode = + abi.encodePacked(vm.getCode("PermissionsAdapterFactory.sol:PermissionsAdapterFactory"), args); + assembly { + sstore(permissionsAdapterFactory.slot, create(0, add(initcode, 0x20), mload(initcode))) + } + } + + function test_InitialState() public view { + assertEq(permissionsAdapterFactory.POOL_MANAGER(), mockPoolManager); + } + + function test_CreatePermissionsAdapter(address initialOwner) public { + vm.assume(initialOwner != address(0)); + address expectedPermissionsAdapter = vm.computeCreateAddress(address(permissionsAdapterFactory), 1); + vm.expectEmit(true, true, true, true); + emit IPermissionsAdapterFactory.PermissionsAdapterCreated( + expectedPermissionsAdapter, address(permissionedToken) + ); + address permissionsAdapter = + permissionsAdapterFactory.createPermissionsAdapter(permissionedToken, initialOwner, allowlistChecker); + assertEq(permissionsAdapter, expectedPermissionsAdapter); + assertEq(permissionsAdapterFactory.permissionsAdapterOf(permissionsAdapter), address(permissionedToken)); + assertEq(permissionsAdapterFactory.verifiedPermissionsAdapterOf(permissionsAdapter), address(0)); + } + + function testRevert_WhenPemissionsAdapterNotDeployed(address permissionsAdapter) public { + vm.expectRevert( + abi.encodeWithSelector(IPermissionsAdapterFactory.PermissionsAdapterNotFound.selector, permissionsAdapter) + ); + permissionsAdapterFactory.verifyPermissionsAdapter(permissionsAdapter); + } + + function testRevert_WhenPemissionsAdapterNotVerified() public { + address permissionsAdapter = permissionsAdapterFactory.createPermissionsAdapter( + permissionedToken, makeAddr("initialOwner"), allowlistChecker + ); + vm.expectRevert( + abi.encodeWithSelector(IPermissionsAdapterFactory.PemissionsAdapterNotVerified.selector, permissionsAdapter) + ); + permissionsAdapterFactory.verifyPermissionsAdapter(permissionsAdapter); + } + + function test_VerifyPemissionsAdapter() public { + address permissionsAdapter = permissionsAdapterFactory.createPermissionsAdapter( + permissionedToken, makeAddr("initialOwner"), allowlistChecker + ); + permissionedToken.setAllowlist(permissionsAdapter, PermissionFlags.ALL_ALLOWED); + permissionedToken.mint(permissionsAdapter, 1); + vm.expectEmit(true, true, true, true); + emit IPermissionsAdapterFactory.PemissionsAdapterVerified(permissionsAdapter, address(permissionedToken)); + permissionsAdapterFactory.verifyPermissionsAdapter(permissionsAdapter); + assertEq(permissionsAdapterFactory.verifiedPermissionsAdapterOf(permissionsAdapter), address(permissionedToken)); + } + + function testRevert_WhenPemissionsAdapterAlreadyVerified() public { + address permissionsAdapter = permissionsAdapterFactory.createPermissionsAdapter( + permissionedToken, makeAddr("initialOwner"), allowlistChecker + ); + permissionedToken.setAllowlist(permissionsAdapter, PermissionFlags.ALL_ALLOWED); + permissionedToken.mint(permissionsAdapter, 1); + permissionsAdapterFactory.verifyPermissionsAdapter(permissionsAdapter); + vm.expectRevert( + abi.encodeWithSelector( + IPermissionsAdapterFactory.PemissionsAdapterAlreadyVerified.selector, permissionsAdapter + ) + ); + permissionsAdapterFactory.verifyPermissionsAdapter(permissionsAdapter); + } +} diff --git a/test/hooks/permissionedPools/mocks/MockInsecureHooks.sol b/test/hooks/permissionedPools/mocks/MockInsecureHooks.sol new file mode 100644 index 000000000..3573effb7 --- /dev/null +++ b/test/hooks/permissionedPools/mocks/MockInsecureHooks.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {ModifyLiquidityParams, SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; + +/// @notice This contract is used in the testing of security for the permissioned pool manager +contract MockInsecureHooks { + function beforeAddLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) + external + pure + returns (bytes4) + { + return IHooks.beforeAddLiquidity.selector; + } + + function beforeSwap(address, PoolKey calldata, SwapParams calldata, bytes calldata) + external + pure + returns (bytes4, BeforeSwapDelta, uint24) + { + return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + } + + receive() external payable {} +} diff --git a/test/hooks/permissionedPools/mocks/MockPermissionedHooks.sol b/test/hooks/permissionedPools/mocks/MockPermissionedHooks.sol new file mode 100644 index 000000000..488680035 --- /dev/null +++ b/test/hooks/permissionedPools/mocks/MockPermissionedHooks.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {ModifyLiquidityParams, SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {Hooks, IHooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IMsgSender} from "../../../../src/interfaces/IMsgSender.sol"; +import {IPermissionsAdapter} from "../../../../src/hooks/permissionedPools/interfaces/IPermissionsAdapter.sol"; +import { + IPermissionsAdapterFactory +} from "../../../../src/hooks/permissionedPools/interfaces/IPermissionsAdapterFactory.sol"; +import {PermissionFlags, PermissionFlag} from "../../../../src/hooks/permissionedPools/libraries/PermissionFlags.sol"; + +/// @notice Standalone mock that replicates PermissionedHooks logic without inheriting BaseHook +contract MockPermissionedHooks { + error Unauthorized(); + error SwappingDisabled(); + error NoVerifiedAdapter(); + error HookNotImplemented(); + + IPoolManager public immutable manager; + IPermissionsAdapterFactory public immutable PERMISSIONS_ADAPTER_FACTORY; + + constructor(IPoolManager _manager, IPermissionsAdapterFactory _permissionsAdapterFactory) { + manager = _manager; + PERMISSIONS_ADAPTER_FACTORY = _permissionsAdapterFactory; + } + + function beforeInitialize(address, PoolKey calldata key, uint160) external view returns (bytes4) { + bool currency0Verified = + PERMISSIONS_ADAPTER_FACTORY.verifiedPermissionsAdapterOf(Currency.unwrap(key.currency0)) != address(0); + bool currency1Verified = + PERMISSIONS_ADAPTER_FACTORY.verifiedPermissionsAdapterOf(Currency.unwrap(key.currency1)) != address(0); + if (!currency0Verified && !currency1Verified) revert NoVerifiedAdapter(); + return IHooks.beforeInitialize.selector; + } + + function beforeSwap(address sender, PoolKey calldata key, SwapParams calldata, bytes calldata) + external + view + returns (bytes4, BeforeSwapDelta, uint24) + { + _verifyAllowlist(IMsgSender(sender), key, IHooks.beforeSwap.selector); + return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + } + + function beforeAddLiquidity(address sender, PoolKey calldata key, ModifyLiquidityParams calldata, bytes calldata) + external + view + returns (bytes4) + { + _verifyAllowlist(IMsgSender(sender), key, IHooks.beforeAddLiquidity.selector); + return IHooks.beforeAddLiquidity.selector; + } + + function afterSwap(address, PoolKey calldata, SwapParams calldata, BalanceDelta, bytes calldata) + external + pure + returns (bytes4, int128) + { + revert HookNotImplemented(); + } + + function afterInitialize(address, PoolKey calldata, uint160, int24) external pure returns (bytes4) { + revert HookNotImplemented(); + } + + function beforeRemoveLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) + external + pure + returns (bytes4) + { + revert HookNotImplemented(); + } + + function afterRemoveLiquidity( + address, + PoolKey calldata, + ModifyLiquidityParams calldata, + BalanceDelta, + BalanceDelta, + bytes calldata + ) external pure returns (bytes4, BalanceDelta) { + revert HookNotImplemented(); + } + + function afterAddLiquidity( + address, + PoolKey calldata, + ModifyLiquidityParams calldata, + BalanceDelta, + BalanceDelta, + bytes calldata + ) external pure returns (bytes4, BalanceDelta) { + revert HookNotImplemented(); + } + + function beforeDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { + revert HookNotImplemented(); + } + + function afterDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { + revert HookNotImplemented(); + } + + function _verifyAllowlist(IMsgSender sender, PoolKey calldata poolKey, bytes4 selector) internal view { + _isAllowed(Currency.unwrap(poolKey.currency0), sender.msgSender(), address(sender), selector); + _isAllowed(Currency.unwrap(poolKey.currency1), sender.msgSender(), address(sender), selector); + } + + function _isAllowed(address permissionsAdapter, address sender, address router, bytes4 selector) internal view { + address permissionedToken = PERMISSIONS_ADAPTER_FACTORY.verifiedPermissionsAdapterOf(permissionsAdapter); + if (permissionedToken == address(0)) return; + + PermissionFlag permission = PermissionFlags.NONE; + if (selector == IHooks.beforeSwap.selector) { + permission = PermissionFlags.SWAP_ALLOWED; + if (!IPermissionsAdapter(permissionsAdapter).swappingEnabled()) revert SwappingDisabled(); + } else if (selector == IHooks.beforeAddLiquidity.selector) { + permission = PermissionFlags.LIQUIDITY_ALLOWED; + } + + if ( + !IPermissionsAdapter(permissionsAdapter).isAllowed(sender, permission) + || !IPermissionsAdapter(permissionsAdapter).allowedWrappers(router) + ) revert Unauthorized(); + } +} diff --git a/test/hooks/permissionedPools/shared/PermissionedDeployers.sol b/test/hooks/permissionedPools/shared/PermissionedDeployers.sol new file mode 100644 index 000000000..311890109 --- /dev/null +++ b/test/hooks/permissionedPools/shared/PermissionedDeployers.sol @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +import {ModifyLiquidityParams, SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {Constants} from "@uniswap/v4-core/test/utils/Constants.sol"; +import {SortTokens} from "@uniswap/v4-core/test/utils/SortTokens.sol"; +import {PoolModifyLiquidityTest} from "@uniswap/v4-core/src/test/PoolModifyLiquidityTest.sol"; +import {PoolModifyLiquidityTestNoChecks} from "@uniswap/v4-core/src/test/PoolModifyLiquidityTestNoChecks.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {SwapRouterNoChecks} from "@uniswap/v4-core/src/test/SwapRouterNoChecks.sol"; +import {PoolDonateTest} from "@uniswap/v4-core/src/test/PoolDonateTest.sol"; +import {PoolNestedActionsTest} from "@uniswap/v4-core/src/test/PoolNestedActionsTest.sol"; +import {PoolTakeTest} from "@uniswap/v4-core/src/test/PoolTakeTest.sol"; +import {PoolClaimsTest} from "@uniswap/v4-core/src/test/PoolClaimsTest.sol"; +import {ActionsRouter} from "@uniswap/v4-core/src/test/ActionsRouter.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import {Planner} from "../../../shared/Planner.sol"; +import {Plan} from "../../../shared/Planner.sol"; +import {IV4Router} from "../../../../src/interfaces/IV4Router.sol"; +import {Actions} from "../../../../src/libraries/Actions.sol"; +import {ActionConstants} from "../../../../src/libraries/ActionConstants.sol"; +import { + IPermissionsAdapterFactory +} from "../../../../src/hooks/permissionedPools/interfaces/IPermissionsAdapterFactory.sol"; +import {MockPermissionedRouter} from "../../../mocks/MockPermissionedRouter.sol"; +import {MockPermissionedToken} from "../PermissionedPoolsBase.sol"; +import {MockV4Router} from "../../../mocks/MockV4Router.sol"; +import {MockInsecureHooks} from "../mocks/MockInsecureHooks.sol"; +import {MockPermissionedHooks} from "../mocks/MockPermissionedHooks.sol"; +import {Deploy} from "../../../../test/shared/Deploy.sol"; +import {HookMiner} from "../../../../src/utils/HookMiner.sol"; +import {IWETH9} from "../../../../src/interfaces/external/IWETH9.sol"; +import {PermissionFlags} from "../../../../src/hooks/permissionedPools/libraries/PermissionFlags.sol"; + +/// @notice A contract that provides permissioned deployment functionality for tests +/// This moves the deployFreshManagerAndRoutersPermissioned function from v4-core to the test folder +contract PermissionedDeployers is Test { + using LPFeeLibrary for uint24; + using StateLibrary for IPoolManager; + + // Helpful test constants + bytes public constant ZERO_BYTES = Constants.ZERO_BYTES; + uint160 public constant SQRT_PRICE_1_1 = Constants.SQRT_PRICE_1_1; + uint160 public constant SQRT_PRICE_1_2 = Constants.SQRT_PRICE_1_2; + uint160 public constant SQRT_PRICE_2_1 = Constants.SQRT_PRICE_2_1; + uint160 public constant SQRT_PRICE_1_4 = Constants.SQRT_PRICE_1_4; + uint160 public constant SQRT_PRICE_4_1 = Constants.SQRT_PRICE_4_1; + + uint160 public constant MIN_PRICE_LIMIT = TickMath.MIN_SQRT_PRICE + 1; + uint160 public constant MAX_PRICE_LIMIT = TickMath.MAX_SQRT_PRICE - 1; + + ModifyLiquidityParams public LIQUIDITY_PARAMS = + ModifyLiquidityParams({tickLower: -120, tickUpper: 120, liquidityDelta: 1e18, salt: 0}); + ModifyLiquidityParams public REMOVE_LIQUIDITY_PARAMS = + ModifyLiquidityParams({tickLower: -120, tickUpper: 120, liquidityDelta: -1e18, salt: 0}); + SwapParams public SWAP_PARAMS = + SwapParams({zeroForOne: true, amountSpecified: -100, sqrtPriceLimitX96: SQRT_PRICE_1_2}); + + // Global variables + Currency internal currency0; + Currency internal currency1; + + IPoolManager public manager; + PoolModifyLiquidityTest public modifyLiquidityRouter; + PoolModifyLiquidityTestNoChecks public modifyLiquidityNoChecks; + SwapRouterNoChecks public swapRouterNoChecks; + MockPermissionedRouter public permissionedSwapRouter; + PoolSwapTest public swapRouter; + PoolDonateTest public donateRouter; + PoolTakeTest public takeRouter; + ActionsRouter public actionsRouter; + IHooks public permissionedHooks; + IHooks public secondaryPermissionedHooks; + IHooks public insecureHooks; + IPermissionsAdapterFactory public permissionsAdapterFactory; + + PoolClaimsTest public claimsRouter; + PoolNestedActionsTest public nestedActionRouter; + + address public feeController; + + PoolKey public nativeKey; + PoolKey public uninitializedKey; + PoolKey public uninitializedNativeKey; + + modifier noIsolate() { + if (msg.sender != address(this)) { + (bool success,) = address(this).call(msg.data); + require(success); + } else { + _; + } + } + + function deployFreshManager() internal virtual { + manager = new PoolManager(address(this)); + } + + function deployFreshManagerAndRouters() internal { + deployFreshManager(); + + swapRouter = new PoolSwapTest(manager); + deployMiscRouters(); + } + + function deployMiscRouters() internal { + swapRouterNoChecks = new SwapRouterNoChecks(manager); + modifyLiquidityRouter = new PoolModifyLiquidityTest(manager); + modifyLiquidityNoChecks = new PoolModifyLiquidityTestNoChecks(manager); + donateRouter = new PoolDonateTest(manager); + takeRouter = new PoolTakeTest(manager); + claimsRouter = new PoolClaimsTest(manager); + nestedActionRouter = new PoolNestedActionsTest(manager); + feeController = makeAddr("feeController"); + actionsRouter = new ActionsRouter(manager); + manager.setProtocolFeeController(feeController); + } + + function deployPermissionedHooks(address manager_, address permissionsAdapterFactory_) + internal + returns (address deployedHooksAddr) + { + uint160 flags = (1 << 13) | (1 << 11) | (1 << 7); + (address calculatedAddr, bytes32 salt) = HookMiner.find( + address(this), + flags, + vm.getCode("MockPermissionedHooks.sol:MockPermissionedHooks"), + abi.encode(IPoolManager(manager_), IPermissionsAdapterFactory(permissionsAdapterFactory_)) + ); + address addr = Deploy.create2( + abi.encodePacked( + vm.getCode("MockPermissionedHooks.sol:MockPermissionedHooks"), + abi.encode(IPoolManager(manager_), IPermissionsAdapterFactory(permissionsAdapterFactory_)) + ), + salt + ); + assertEq(addr, calculatedAddr); + deployedHooksAddr = calculatedAddr; + } + + function deployInsecureHooks(address manager_) internal returns (address deployedHooksAddr) { + uint160 flags = (1 << 11) | (1 << 7); + (address calculatedAddr, bytes32 salt) = HookMiner.find( + address(this), + flags, + vm.getCode("MockInsecureHooks.sol:MockInsecureHooks"), + abi.encode(IPoolManager(manager_)) + ); + address addr = Deploy.create2( + abi.encodePacked(vm.getCode("MockInsecureHooks.sol:MockInsecureHooks"), abi.encode(IPoolManager(manager_))), + salt + ); + assertEq(addr, calculatedAddr); + deployedHooksAddr = calculatedAddr; + } + + function deployPermissionedV4Router(address permit2_, address permissionsAdapterFactory_, address weth9) + internal + returns (address deployedAddr) + { + bytes memory routerBytecode = abi.encodePacked( + vm.getCode("MockPermissionedRouter.sol:MockPermissionedRouter"), + abi.encode( + manager, + IAllowanceTransfer(permit2_), + IPermissionsAdapterFactory(permissionsAdapterFactory_), + IWETH9(address(weth9)) + ) + ); + + deployedAddr = Deploy.create2(routerBytecode, keccak256("permissionedSwapRouter")); + } + + function deployFreshManagerAndRoutersPermissioned(address permit2_, address weth9) internal { + deployFreshManager(); + + address permissionsAdapterFactoryAddress = Deploy.create2( + abi.encodePacked( + vm.getCode("PermissionsAdapterFactory.sol:PermissionsAdapterFactory"), abi.encode(address(manager)) + ), + keccak256("permissionsAdapterFactory") + ); + + permissionsAdapterFactory = IPermissionsAdapterFactory(permissionsAdapterFactoryAddress); + permissionedHooks = IHooks(deployPermissionedHooks(address(manager), permissionsAdapterFactoryAddress)); + secondaryPermissionedHooks = IHooks(deployPermissionedHooks(address(manager), permissionsAdapterFactoryAddress)); + insecureHooks = IHooks(deployInsecureHooks(address(manager))); + address deployedAddr = deployPermissionedV4Router(permit2_, permissionsAdapterFactoryAddress, weth9); + permissionedSwapRouter = MockPermissionedRouter(payable(deployedAddr)); + swapRouter = PoolSwapTest(deployedAddr); + deployMiscRouters(); + } + + /// @dev You must have first initialised the routers with deployFreshManagerAndRouters + /// If you only need the currencies (and not approvals) call deployAndMint2Currencies + function deployMintAndApprove2Currencies(bool isPermissioned0, bool isPermissioned1) + internal + returns (Currency, Currency) + { + while (true) { + Currency _currency0; + Currency _currency1; + if (isPermissioned0) { + _currency0 = deployMintAndApproveCurrency(true); + } else { + _currency0 = deployMintAndApproveCurrency(false); + } + if (isPermissioned1) { + _currency1 = deployMintAndApproveCurrency(true); + } else { + _currency1 = deployMintAndApproveCurrency(false); + } + + (currency0, currency1) = + SortTokens.sort(MockERC20(Currency.unwrap(_currency0)), MockERC20(Currency.unwrap(_currency1))); + if (currency0 == _currency0 && currency1 == _currency1) { + break; + } + } + return (currency0, currency1); + } + + function deployMintAndApproveCurrency(bool isPermissioned) internal returns (Currency currency) { + MockERC20 token = deployTokens(1, 2 ** 255, isPermissioned)[0]; + + address[9] memory toApprove = [ + address(swapRouter), + address(swapRouterNoChecks), + address(modifyLiquidityRouter), + address(modifyLiquidityNoChecks), + address(donateRouter), + address(takeRouter), + address(claimsRouter), + address(nestedActionRouter.executor()), + address(actionsRouter) + ]; + + for (uint256 i = 0; i < toApprove.length; i++) { + token.approve(toApprove[i], Constants.MAX_UINT256); + } + + return Currency.wrap(address(token)); + } + + function deployTokens(uint8 count, uint256 totalSupply, bool arePermissioned) + internal + returns (MockERC20[] memory tokens) + { + tokens = new MockERC20[](count); + for (uint8 i = 0; i < count; i++) { + if (arePermissioned) { + tokens[i] = MockERC20(address(new MockPermissionedToken())); + MockPermissionedToken(address(tokens[i])).setAllowlist(address(this), PermissionFlags.ALL_ALLOWED); + tokens[i].mint(address(this), totalSupply); + } else { + tokens[i] = new MockERC20("TEST", "TEST", 18); + tokens[i].mint(address(this), totalSupply); + } + } + } + + function initPool(Currency _currency0, Currency _currency1, IHooks hooks, uint24 fee, uint160 sqrtPriceX96) + internal + returns (PoolKey memory _key, PoolId id) + { + return initPool( + _currency0, _currency1, hooks, fee, fee.isDynamicFee() ? int24(60) : int24(fee / 100 * 2), sqrtPriceX96 + ); + } + + function initPool( + Currency _currency0, + Currency _currency1, + IHooks hooks, + uint24 fee, + int24 tickSpacing, + uint160 sqrtPriceX96 + ) internal returns (PoolKey memory _key, PoolId id) { + _key = PoolKey(_currency0, _currency1, fee, tickSpacing, hooks); + id = _key.toId(); + manager.initialize(_key, sqrtPriceX96); + } + + /// @notice Helper function for a simple ERC20 swaps that allows for unlimited price impact + function swap(PoolKey memory _key, bool zeroForOne, int256 amountSpecified) internal { + // allow native input for exact-input, guide users to the `swapNativeInput` function + bool isNativeInput = zeroForOne && _key.currency0.isAddressZero(); + if (isNativeInput) require(0 > amountSpecified, "Use swapNativeInput() for native-token exact-output swaps"); + + uint256 value = isNativeInput ? uint256(-amountSpecified) : 0; + bytes memory data = getSwapData(_key, uint256(-amountSpecified), zeroForOne); + bytes memory command = hex"10"; + permissionedSwapRouter.execute{value: value}(command, toBytesArray(data), type(uint256).max); + } + + function getSwapData(PoolKey memory poolKey, uint256 amountIn, bool zeroForOne) + internal + pure + returns (bytes memory) + { + // Initialize the plan + Plan memory plan = Planner.init(); + + // Create swap parameters + IV4Router.ExactInputSingleParams memory params = IV4Router.ExactInputSingleParams( + poolKey, // The pool to swap in + zeroForOne, // Direction of swap + uint128(amountIn), // Amount to swap in + 0, // Minimum amount out (0 = no slippage protection) + 0, + bytes("") // Hook data + ); + + // Add the swap action to the plan + plan = plan.add(Actions.SWAP_EXACT_IN_SINGLE, abi.encode(params)); + + // Finalize the plan - this adds SETTLE and TAKE actions + bytes memory data = plan.finalizeSwap( + zeroForOne ? poolKey.currency0 : poolKey.currency1, // Input currency + zeroForOne ? poolKey.currency1 : poolKey.currency0, // Output currency + ActionConstants.MSG_SENDER // Take recipient + ); + + return data; + } + + function toBytesArray(bytes memory data) internal pure returns (bytes[] memory) { + bytes[] memory result = new bytes[](1); + result[0] = data; + return result; + } +} diff --git a/test/hooks/permissionedPools/shared/PermissionedPosmTestSetup.sol b/test/hooks/permissionedPools/shared/PermissionedPosmTestSetup.sol new file mode 100644 index 000000000..7caa4592d --- /dev/null +++ b/test/hooks/permissionedPools/shared/PermissionedPosmTestSetup.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {IERC721} from "forge-std/interfaces/IERC721.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 {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {WETH} from "solmate/src/tokens/WETH.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {DeployPermit2} from "permit2/test/utils/DeployPermit2.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {HookModifyLiquidities} from "../../../shared/HookModifyLiquidities.sol"; +import {Deploy, IPositionDescriptor} from "../../../shared/Deploy.sol"; +import {ERC721PermitHash} from "../../../../src/libraries/ERC721PermitHash.sol"; +import {IWETH9} from "../../../../src/interfaces/external/IWETH9.sol"; +import {LiquidityOperations} from "../../../shared/LiquidityOperations.sol"; +import {PositionConfig} from "../../../shared/PositionConfig.sol"; +import {PermissionedDeployers} from "./PermissionedDeployers.sol"; +import {IPositionManager} from "../../../../src/interfaces/IPositionManager.sol"; +import {Planner, Plan} from "../../../shared/Planner.sol"; +import {ActionConstants} from "../../../../src/libraries/ActionConstants.sol"; +import {Actions} from "../../../../src/libraries/Actions.sol"; + +struct BalanceInfo { + uint256 balance0; + uint256 balance1; + uint256 balance0Manager; + uint256 balance1Manager; +} + +/// @notice A shared test contract that wraps the v4-core deployers contract and exposes basic liquidity operations on posm. +contract PermissionedPosmTestSetup is Test, PermissionedDeployers, DeployPermit2, LiquidityOperations { + uint256 private constant STARTING_USER_BALANCE = 10_000_000 ether; + address public constant GOVERNANCE = address(0xABCD); + + IAllowanceTransfer public permit2; + IPositionDescriptor public positionDescriptor; + TransparentUpgradeableProxy public proxy; + IPositionDescriptor public proxyAsImplementation; + HookModifyLiquidities public hookModifyLiquidities; + Currency public currency2; + IWETH9 public _WETH9; + + WETH public wethImpl = new WETH(); + + PoolKey public wethKey; + + mapping(Currency => Currency) public adapterToPermissioned; + + function deployAndApprovePosm(IPoolManager poolManager, address permissionsAdapterFactory_, bytes32 salt) public { + deployPermissionedPosm(poolManager, permissionsAdapterFactory_, salt); + approvePosm(); + } + + function deployPermissionedPosm(IPoolManager poolManager, address permissionsAdapterFactory_, bytes32 salt) + internal + { + permit2 = IAllowanceTransfer(deployPermit2()); + _WETH9 = deployWETH(); + proxyAsImplementation = deployDescriptor(poolManager, "ETH"); + lpm = Deploy.permissionedPositionManager( + address(poolManager), + address(permit2), + 100_000, + address(proxyAsImplementation), + address(_WETH9), + permissionsAdapterFactory_, + abi.encode(salt) + ); + } + + function deployAndApprovePosmOnly(IPoolManager poolManager, address permissionsAdapterFactory_, bytes32 salt) + public + returns (IPositionManager secondaryPosm) + { + secondaryPosm = Deploy.permissionedPositionManager( + address(poolManager), + address(permit2), + 100_000, + address(proxyAsImplementation), + address(_WETH9), + permissionsAdapterFactory_, + abi.encode(salt) + ); + approvePosm(); + } + + function deployWETH() internal returns (IWETH9) { + address wethAddr = makeAddr("WETH"); + vm.etch(wethAddr, address(wethImpl).code); + return IWETH9(wethAddr); + } + + function deployDescriptor(IPoolManager poolManager_, bytes32 label) internal returns (IPositionDescriptor) { + positionDescriptor = Deploy.positionDescriptor(address(poolManager_), address(_WETH9), label, hex"00"); + proxy = Deploy.transparentUpgradeableProxy(address(positionDescriptor), GOVERNANCE, "", hex"03"); + return IPositionDescriptor(address(proxy)); + } + + function seedBalance(address to) internal { + IERC20(Currency.unwrap(currency0)).transfer(to, STARTING_USER_BALANCE); + IERC20(Currency.unwrap(currency1)).transfer(to, STARTING_USER_BALANCE); + IERC20(Currency.unwrap(currency2)).transfer(to, STARTING_USER_BALANCE); + } + + function approvePosm() internal { + approvePosmCurrency(currency0); + approvePosmCurrency(currency1); + approvePosmCurrency(currency2); + } + + function approvePosmCurrency(Currency currency) internal { + // Because POSM uses permit2, we must execute 2 permits/approvals. + // 1. First, the caller must approve permit2 on the token. + IERC20(Currency.unwrap(currency)).approve(address(permit2), type(uint256).max); + // 2. Then, the caller must approve POSM as a spender of permit2. + permit2.approve(Currency.unwrap(currency), address(lpm), type(uint160).max, type(uint48).max); + } + + // Does the same approvals as approvePosm, but for a specific address. + /// @dev Should not be in a prank when calling this function + function approvePosmFor(address addr) internal { + vm.startPrank(addr); + approvePosm(); + vm.stopPrank(); + } + + function getDigest(address spender, uint256 tokenId, uint256 nonce, uint256 deadline) + internal + view + returns (bytes32 digest) + { + digest = keccak256( + abi.encodePacked( + "\x19\x01", + lpm.DOMAIN_SEPARATOR(), + keccak256(abi.encode(ERC721PermitHash.PERMIT_TYPEHASH, spender, tokenId, nonce, deadline)) + ) + ); + } + + function getPermissionedCurrency(Currency currency) internal view returns (Currency) { + Currency permissionedCurrency = adapterToPermissioned[currency]; + if (permissionedCurrency == Currency.wrap(address(0))) { + return currency; + } + return permissionedCurrency; + } + + function setupContractBalance(PoolKey memory key, uint256 amount0ToTransfer, uint256 amount1ToTransfer) internal { + getPermissionedCurrency(key.currency0).transfer(address(lpm), amount0ToTransfer); + getPermissionedCurrency(key.currency1).transfer(address(lpm), amount1ToTransfer); + + assertEq(getPermissionedCurrency(key.currency0).balanceOf(address(lpm)), amount0ToTransfer); + assertEq(getPermissionedCurrency(key.currency1).balanceOf(address(lpm)), amount1ToTransfer); + } + + /// @dev This function is used to avoid stack-too-deep errors + function getBalanceInfoSelfAndManager(PoolKey memory key) internal view returns (BalanceInfo memory) { + return BalanceInfo({ + balance0: getPermissionedCurrency(key.currency0).balanceOfSelf(), + balance1: getPermissionedCurrency(key.currency1).balanceOfSelf(), + balance0Manager: key.currency0.balanceOf(address(manager)), + balance1Manager: key.currency1.balanceOf(address(manager)) + }); + } + + /// @dev This function is used to avoid stack-too-deep errors + function getBalanceInfo(PoolKey memory key) internal view returns (BalanceInfo memory) { + return BalanceInfo({ + balance0: getPermissionedCurrency(key.currency0).balanceOf(address(lpm)), + balance1: getPermissionedCurrency(key.currency1).balanceOf(address(lpm)), + balance0Manager: key.currency0.balanceOf(address(manager)), + balance1Manager: key.currency1.balanceOf(address(manager)) + }); + } + + function createMintPlan(PoolKey memory key, uint256 amount0ToTransfer, uint256 amount1ToTransfer) + internal + view + returns (bytes memory) + { + int24 tickLower = -int24(key.tickSpacing); + int24 tickUpper = int24(key.tickSpacing); + uint256 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(tickLower), + TickMath.getSqrtPriceAtTick(tickUpper), + amount0ToTransfer, + amount1ToTransfer + ); + + PositionConfig memory config = PositionConfig({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); + + Plan memory planner = Planner.init(); + planner.add( + Actions.MINT_POSITION, + abi.encode( + config.poolKey, + config.tickLower, + config.tickUpper, + liquidityToAdd, + MAX_SLIPPAGE_INCREASE, + MAX_SLIPPAGE_INCREASE, + address(this), // recipient + ZERO_BYTES // hookData + ) + ); + + planner.add(Actions.SETTLE, abi.encode(key.currency0, ActionConstants.OPEN_DELTA, false)); + planner.add(Actions.SETTLE, abi.encode(key.currency1, ActionConstants.OPEN_DELTA, false)); + + return planner.finalizeModifyLiquidityWithClose(config.poolKey); + } + + function verifyMintResults(PoolKey memory key, BalanceInfo memory balanceInfoBefore, uint256 tokenId) + internal + view + { + uint256 balance0After = getPermissionedCurrency(key.currency0).balanceOf(address(lpm)); + uint256 balance1After = getPermissionedCurrency(key.currency1).balanceOf(address(lpm)); + uint256 balance0ManagerAfter = key.currency0.balanceOf(address(manager)); + uint256 balance1ManagerAfter = key.currency1.balanceOf(address(manager)); + uint256 liquidity = lpm.getPositionLiquidity(tokenId); + + assertEq(tokenId, lpm.nextTokenId() - 1); + assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this)); + assertGt(balanceInfoBefore.balance0, balance0After); + assertGt(balanceInfoBefore.balance1, balance1After); + assertEq(balanceInfoBefore.balance0 - balance0After, balance0ManagerAfter - balanceInfoBefore.balance0Manager); + assertEq(balanceInfoBefore.balance1 - balance1After, balance1ManagerAfter - balanceInfoBefore.balance1Manager); + assertEq( + liquidity, + LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(-int24(key.tickSpacing)), + TickMath.getSqrtPriceAtTick(int24(key.tickSpacing)), + 100e18, + 100e18 + ) + ); + } +} diff --git a/test/hooks/permissionedPools/shared/PermissionedRoutingTestHelpers.sol b/test/hooks/permissionedPools/shared/PermissionedRoutingTestHelpers.sol new file mode 100644 index 000000000..a02804472 --- /dev/null +++ b/test/hooks/permissionedPools/shared/PermissionedRoutingTestHelpers.sol @@ -0,0 +1,477 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {DeployPermit2} from "permit2/test/utils/DeployPermit2.sol"; +import {WETH} from "solmate/src/tokens/WETH.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import {Deploy} from "test/shared/Deploy.sol"; +import {MockPermissionedRouter} from "../../../mocks/MockPermissionedRouter.sol"; +import {Plan, Planner} from "../../../shared/Planner.sol"; +import {PathKey} from "../../../../src/libraries/PathKey.sol"; +import {Actions} from "../../../../src/libraries/Actions.sol"; +import {PermissionsAdapter} from "../../../../src/hooks/permissionedPools/PermissionsAdapter.sol"; +import {MockAllowlistChecker, MockPermissionedToken} from "../PermissionedPoolsBase.sol"; +import {IAllowlistChecker} from "../../../../src/hooks/permissionedPools/interfaces/IAllowlistChecker.sol"; +import {IPositionManager} from "../../../../src/interfaces/IPositionManager.sol"; +import { + IPermissionsAdapterFactory +} from "../../../../src/hooks/permissionedPools/interfaces/IPermissionsAdapterFactory.sol"; +import {IWETH9} from "../../../../src/interfaces/external/IWETH9.sol"; +import {IPositionDescriptor} from "../../../../src/interfaces/IPositionDescriptor.sol"; +import {IV4Router} from "../../../../src/interfaces/IV4Router.sol"; +import {ActionConstants} from "../../../../src/libraries/ActionConstants.sol"; +import {PermissionedDeployers} from "./PermissionedDeployers.sol"; +import {PermissionFlags} from "../../../../src/hooks/permissionedPools/libraries/PermissionFlags.sol"; + +/// @notice A shared test contract that wraps the v4-core deployers contract and exposes basic helpers for swapping with the permissioned router. +contract PermissionedRoutingTestHelpers is PermissionedDeployers, DeployPermit2 { + uint256 public constant MAX_SETTLE_AMOUNT = type(uint256).max; + uint256 public constant MIN_TAKE_AMOUNT = 0; + + // Permissioned components + MockPermissionedRouter public permissionedRouter; + IAllowanceTransfer public permit2; + MockAllowlistChecker public mockAllowlistChecker; + IAllowlistChecker public allowListChecker; + IPositionDescriptor public tokenDescriptor; + IWETH9 public weth9; + + // nativeKey is already defined in Deployers.sol + PoolKey public key0; + PoolKey public key1; + PoolKey public key2; + PoolKey public key3; + PoolKey public insecureKey; + + // currency0 and currency1 are defined in Deployers.sol + Currency public currency2; + Currency public currency3; + Currency public currency4; + + PermissionsAdapter public permissionsAdapter0; + PermissionsAdapter public permissionsAdapter1; + + Currency[] public tokenPath; + Plan public plan; + + address public positionManager; + + mapping(Currency permissionsAdapter => Currency permissionedCurrency) public adapterToPermissioned; + + function setupPermissionedRouterCurrenciesAndPoolsWithLiquidity(address spender) public { + _deployFreshManager(); + _deployWETH(); + _deployPositionDescriptor(); + _deployPermit2(); + _deployPemissionsAdapterFactory(); + _deployPermissionedHooks(); + _deployInsecureHook(); + _deployMockPermissionedRouter(); + _deployPositionManager(); + MockERC20[] memory tokens = _deployTokensMintAndApprove(5, 2); + _deployAndSetupTokens(tokens); + _setupPermissionedTokens(spender); + _setupApprovals(tokens, spender); + _createPoolsWithLiquidity(); + } + + function approveAllCurrencies(MockERC20[] memory currencies) internal { + for (uint256 i = 0; i < currencies.length; i++) { + approveAllContracts(address(currencies[i])); + } + } + + function approveAllContracts(address token) internal { + approveBoth(token, address(permissionedHooks)); + approveBoth(token, address(permissionedRouter)); + approveBoth(token, address(positionManager)); + approveBoth(token, address(manager)); + approveBoth(token, address(permit2)); + } + + function approveBoth(address token, address approved) internal { + permit2.approve(token, address(approved), type(uint160).max, 2 ** 47); + IERC20(token).approve(address(approved), type(uint256).max); + } + + function getPermissionedCurrency(Currency currency) public view returns (Currency) { + Currency permissionedCurrency = adapterToPermissioned[currency]; + if (permissionedCurrency == Currency.wrap(address(0))) { + return currency; + } + return permissionedCurrency; + } + + function setupPermissionedTokens(address spender) internal { + _setupMockAllowList(currency0, spender); + _setupMockAllowList(currency1, spender); + while (true) { + permissionsAdapter0 = PermissionsAdapter( + permissionsAdapterFactory.createPermissionsAdapter( + IERC20(Currency.unwrap(currency0)), address(this), mockAllowlistChecker + ) + ); + permissionsAdapter1 = PermissionsAdapter( + permissionsAdapterFactory.createPermissionsAdapter( + IERC20(Currency.unwrap(currency1)), address(this), mockAllowlistChecker + ) + ); + if (address(permissionsAdapter0) > address(permissionsAdapter1)) { + break; + } + } + MockPermissionedToken(Currency.unwrap(currency0)) + .setAllowlist(address(permissionsAdapter0), PermissionFlags.ALL_ALLOWED); + MockPermissionedToken(Currency.unwrap(currency1)) + .setAllowlist(address(permissionsAdapter1), PermissionFlags.ALL_ALLOWED); + + // Transfer some underlying tokens to the permissions adapter for verification + IERC20(Currency.unwrap(currency0)).transfer(address(permissionsAdapter0), 1); + IERC20(Currency.unwrap(currency1)).transfer(address(permissionsAdapter1), 1); + + verifyTokensAndAddWrappers(); + + adapterToPermissioned[Currency.wrap(address(permissionsAdapter0))] = currency0; + adapterToPermissioned[Currency.wrap(address(permissionsAdapter1))] = currency1; + } + + function verifyTokensAndAddWrappers() private { + permissionsAdapterFactory.verifyPermissionsAdapter(address(permissionsAdapter0)); + permissionsAdapterFactory.verifyPermissionsAdapter(address(permissionsAdapter1)); + + permissionsAdapter0.updateAllowedWrapper(address(this), true); + permissionsAdapter1.updateAllowedWrapper(address(this), true); + permissionsAdapter0.updateAllowedWrapper(positionManager, true); + permissionsAdapter1.updateAllowedWrapper(positionManager, true); + permissionsAdapter0.updateAllowedWrapper(address(permissionedRouter), true); + permissionsAdapter1.updateAllowedWrapper(address(permissionedRouter), true); + permissionsAdapter0.updateAllowedWrapper(address(permissionedHooks), true); + permissionsAdapter1.updateAllowedWrapper(address(permissionedHooks), true); + + setAllowedHooks( + IPositionManager(positionManager), Currency.wrap(address(permissionsAdapter0)), permissionedHooks + ); + setAllowedHooks( + IPositionManager(positionManager), Currency.wrap(address(permissionsAdapter1)), permissionedHooks + ); + + setAllowedHooks(IPositionManager(positionManager), Currency.wrap(address(permissionsAdapter0)), insecureHooks); + setAllowedHooks(IPositionManager(positionManager), Currency.wrap(address(permissionsAdapter1)), insecureHooks); + } + + function setAllowedHooks(IPositionManager posm, Currency currency, IHooks permissionedHooks_) internal { + // addPermissionedHooks selector + bytes4 selector = 0xb5cdc484; + bytes memory data = abi.encodeWithSelector(selector, currency, permissionedHooks_, true); + (bool success,) = address(posm).call(data); + require(success, "Failed to set hooks"); + } + + function createPoolWithLiquidity(Currency currencyA, Currency currencyB, address hookAddr) + internal + returns (PoolKey memory _key) + { + if (Currency.unwrap(currencyA) > Currency.unwrap(currencyB)) { + (currencyA, currencyB) = (currencyB, currencyA); + } + _key = PoolKey(currencyA, currencyB, 3000, 60, IHooks(hookAddr)); + manager.initialize(_key, SQRT_PRICE_1_1); + MockERC20(Currency.unwrap(currencyA)).approve(positionManager, type(uint256).max); + MockERC20(Currency.unwrap(currencyB)).approve(positionManager, type(uint256).max); + modifyLiquidity(_key, ModifyLiquidityParams(-887220, 887220, 200 ether, 0), "0x", 0); + } + + function createNativePoolWithLiquidity(Currency currency, address hookAddr) internal returns (PoolKey memory _key) { + _key = PoolKey(CurrencyLibrary.ADDRESS_ZERO, currency, 3000, 60, IHooks(hookAddr)); + manager.initialize(_key, SQRT_PRICE_1_1); + MockERC20(Currency.unwrap(currency)).approve(positionManager, type(uint256).max); + modifyLiquidity(_key, ModifyLiquidityParams(-887220, 887220, 200 ether, 0), "0x", 200 ether); + } + + function generatePermitDigest(IAllowanceTransfer.PermitSingle memory permitSingle) internal view returns (bytes32) { + bytes32 domainSeparator = permit2.DOMAIN_SEPARATOR(); + + bytes32 PERMIT_SINGLE_TYPEHASH = keccak256( + "PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)" + ); + + // Hash the permit details + bytes32 permitDetailsHash = keccak256( + abi.encode( + keccak256("PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)"), + permitSingle.details.token, + permitSingle.details.amount, + permitSingle.details.expiration, + permitSingle.details.nonce + ) + ); + + // Hash the permit single data + bytes32 permitSingleHash = keccak256( + abi.encode(PERMIT_SINGLE_TYPEHASH, permitDetailsHash, permitSingle.spender, permitSingle.sigDeadline) + ); + + // Create the final digest + return keccak256(abi.encodePacked("\x19\x01", domainSeparator, permitSingleHash)); + } + + function _getExactInputParams(Currency[] memory _tokenPath, uint256 amountIn) + internal + pure + returns (IV4Router.ExactInputParams memory params) + { + PathKey[] memory path = new PathKey[](_tokenPath.length - 1); + for (uint256 i = 0; i < _tokenPath.length - 1; i++) { + path[i] = PathKey(_tokenPath[i + 1], 3000, 60, IHooks(address(0)), bytes("")); + } + + params.currencyIn = _tokenPath[0]; + params.path = path; + params.amountIn = uint128(amountIn); + params.amountOutMinimum = 0; + } + + function _getExactInputParamsWithHook(Currency[] memory _tokenPath, uint256 amountIn, address hookAddr) + internal + pure + returns (IV4Router.ExactInputParams memory params) + { + return _getExactInputParamsWithHook(_tokenPath, amountIn, hookAddr, 0); + } + + function _getExactInputParamsWithHook( + Currency[] memory _tokenPath, + uint256 amountIn, + address hookAddr, + uint256 amountOutMinimum + ) internal pure returns (IV4Router.ExactInputParams memory params) { + PathKey[] memory path = new PathKey[](_tokenPath.length - 1); + for (uint256 i = 0; i < _tokenPath.length - 1; i++) { + path[i] = PathKey(_tokenPath[i + 1], 3000, 60, IHooks(hookAddr), bytes("")); + } + + params.currencyIn = _tokenPath[0]; + params.path = path; + params.minHopPriceX36 = new uint256[](0); + params.amountIn = uint128(amountIn); + params.amountOutMinimum = uint128(amountOutMinimum); + } + + function _getExactOutputParams(Currency[] memory _tokenPath, uint256 amountOut) + internal + pure + returns (IV4Router.ExactOutputParams memory params) + { + return _getExactOutputParamsWithHook(_tokenPath, amountOut, address(0), type(uint128).max); + } + + function _getExactOutputParamsWithHook( + Currency[] memory _tokenPath, + uint256 amountOut, + address hookAddr, + uint256 amountInMaximum + ) internal pure returns (IV4Router.ExactOutputParams memory params) { + PathKey[] memory path = new PathKey[](_tokenPath.length - 1); + for (uint256 i = _tokenPath.length - 1; i > 0; i--) { + path[i - 1] = PathKey(_tokenPath[i - 1], 3000, 60, IHooks(hookAddr), bytes("")); + } + + params.currencyOut = _tokenPath[_tokenPath.length - 1]; + params.path = path; + params.minHopPriceX36 = new uint256[](0); + params.amountOut = uint128(amountOut); + params.amountInMaximum = uint128(amountInMaximum); + } + + function modifyLiquidity( + PoolKey memory key, + ModifyLiquidityParams memory params, + bytes memory hookData, + uint256 value + ) public { + // Create a plan with the mint position action + plan = Planner.init(); + plan = plan.add( + Actions.MINT_POSITION, + abi.encode(key, params.tickLower, params.tickUpper, 20000, 100000, 100000, msg.sender, hookData) + ); + + // Finalize the plan with proper settlement + bytes memory unlockData = plan.finalizeModifyLiquidityWithSettlePair(key); + uint256 deadline = block.timestamp + 3600; + + // Call modifyLiquidities with the properly encoded data + IPositionManager(positionManager).modifyLiquidities{value: value}(unlockData, deadline); + } + + function _finalizeAndExecuteSwap( + Currency inputCurrency, + Currency outputCurrency, + uint256 amountIn, + address takeRecipient + ) + internal + returns ( + uint256 inputBalanceBefore, + uint256 outputBalanceBefore, + uint256 inputBalanceAfter, + uint256 outputBalanceAfter + ) + { + inputBalanceBefore = getPermissionedCurrency(inputCurrency).balanceOfSelf(); + outputBalanceBefore = getPermissionedCurrency(outputCurrency).balanceOfSelf(); + + bytes memory data = plan.finalizeSwap(inputCurrency, outputCurrency, takeRecipient); + uint256 value = (inputCurrency.isAddressZero()) ? amountIn : 0; + bytes memory command = hex"10"; + + permissionedRouter.execute{value: value}(command, toBytesArray(data), type(uint256).max); + + inputBalanceAfter = getPermissionedCurrency(inputCurrency).balanceOfSelf(); + outputBalanceAfter = getPermissionedCurrency(outputCurrency).balanceOfSelf(); + } + + function _finalizeAndExecuteSwap(Currency inputCurrency, Currency outputCurrency, uint256 amountIn) + internal + returns ( + uint256 inputBalanceBefore, + uint256 outputBalanceBefore, + uint256 inputBalanceAfter, + uint256 outputBalanceAfter + ) + { + return _finalizeAndExecuteSwap(inputCurrency, outputCurrency, amountIn, ActionConstants.MSG_SENDER); + } + + // ===== PRIVATE HELPER FUNCTIONS ===== + + function _deployFreshManager() private { + deployFreshManager(); + } + + function _deployWETH() private { + WETH wethImpl = new WETH(); + address wethAddr = makeAddr("WETH"); + vm.etch(wethAddr, address(wethImpl).code); + weth9 = IWETH9(wethAddr); + } + + function _deployPositionDescriptor() private { + tokenDescriptor = Deploy.positionDescriptor(address(manager), address(weth9), "ETH", hex"00"); + } + + function _deployPermit2() private { + permit2 = IAllowanceTransfer(deployPermit2()); + } + + function _deployPemissionsAdapterFactory() private { + bytes memory permissionsAdapterFactoryBytecode = abi.encodePacked( + vm.getCode("PermissionsAdapterFactory.sol:PermissionsAdapterFactory"), abi.encode(address(manager)) + ); + permissionsAdapterFactory = IPermissionsAdapterFactory( + Deploy.create2(permissionsAdapterFactoryBytecode, keccak256("permissionsAdapterFactory")) + ); + } + + function _deployPermissionedHooks() private { + permissionedHooks = IHooks(deployPermissionedHooks(address(manager), address(permissionsAdapterFactory))); + } + + function _deployInsecureHook() private { + insecureHooks = IHooks(deployInsecureHooks(address(manager))); + } + + function _deployMockPermissionedRouter() private { + bytes memory routerBytecode = abi.encodePacked( + vm.getCode("MockPermissionedRouter.sol:MockPermissionedRouter"), + abi.encode(manager, permit2, permissionsAdapterFactory, weth9) + ); + permissionedRouter = + MockPermissionedRouter(payable(Deploy.create2(routerBytecode, keccak256("permissionedSwapRouter")))); + } + + function _deployPositionManager() private { + bytes memory posmBytecode = abi.encodePacked( + vm.getCode("PermissionedPositionManager.sol:PermissionedPositionManager"), + abi.encode(manager, permit2, 1e18, address(tokenDescriptor), address(weth9), permissionsAdapterFactory) + ); + positionManager = Deploy.create2(posmBytecode, keccak256("permissionedPosm")); + } + + function _deployAndSetupTokens(MockERC20[] memory tokens) private { + currency0 = Currency.wrap(address(tokens[0])); + currency1 = Currency.wrap(address(tokens[1])); + currency2 = Currency.wrap(address(tokens[2])); + currency3 = Currency.wrap(address(tokens[3])); + currency4 = Currency.wrap(address(tokens[4])); + } + + function _setupPermissionedTokens(address spender) private { + setupPermissionedTokens(spender); + } + + function _setupApprovals(MockERC20[] memory tokens, address alice) private { + approveAllCurrencies(tokens); + vm.startPrank(alice); + approveAllCurrencies(tokens); + vm.stopPrank(); + } + + function _createPoolsWithLiquidity() private { + nativeKey = + createNativePoolWithLiquidity(Currency.wrap(address(permissionsAdapter0)), address(permissionedHooks)); + key0 = createPoolWithLiquidity( + Currency.wrap(address(permissionsAdapter0)), + Currency.wrap(address(permissionsAdapter1)), + address(permissionedHooks) + ); + key1 = + createPoolWithLiquidity(Currency.wrap(address(permissionsAdapter1)), currency2, address(permissionedHooks)); + key2 = createPoolWithLiquidity(currency2, currency3, address(0)); + key3 = + createPoolWithLiquidity(Currency.wrap(address(permissionsAdapter0)), currency4, address(permissionedHooks)); + insecureKey = createPoolWithLiquidity( + Currency.wrap(address(permissionsAdapter0)), + Currency.wrap(address(permissionsAdapter1)), + address(insecureHooks) + ); + } + + function _setupMockAllowList(Currency currency, address spender) private { + MockPermissionedToken mockPermissionedToken = MockPermissionedToken(Currency.unwrap(currency)); + mockAllowlistChecker = new MockAllowlistChecker(mockPermissionedToken); + mockPermissionedToken.setAllowlist(address(this), PermissionFlags.ALL_ALLOWED); + mockPermissionedToken.setAllowlist(msg.sender, PermissionFlags.ALL_ALLOWED); + mockPermissionedToken.setAllowlist(address(permissionedRouter), PermissionFlags.ALL_ALLOWED); + mockPermissionedToken.setAllowlist(address(permissionedHooks), PermissionFlags.ALL_ALLOWED); + mockPermissionedToken.setAllowlist(address(positionManager), PermissionFlags.ALL_ALLOWED); + mockPermissionedToken.setAllowlist(address(permissionsAdapterFactory), PermissionFlags.ALL_ALLOWED); + mockPermissionedToken.setAllowlist(address(manager), PermissionFlags.ALL_ALLOWED); + mockPermissionedToken.setAllowlist(address(permit2), PermissionFlags.ALL_ALLOWED); + mockPermissionedToken.setAllowlist(spender, PermissionFlags.ALL_ALLOWED); + } + + function _deployTokensMintAndApprove(uint8 count, uint8 permissionedCount) internal returns (MockERC20[] memory) { + MockERC20[] memory permissionedTokens = deployTokens(permissionedCount, 2 ** 128, true); + MockERC20[] memory unpermissionedTokens = deployTokens(count - permissionedCount, 2 ** 128, false); + MockERC20[] memory tokens = new MockERC20[](count); + for (uint256 i = 0; i < permissionedCount; i++) { + tokens[i] = permissionedTokens[i]; + } + for (uint256 i = 0; i < count - permissionedCount; i++) { + tokens[i + permissionedCount] = unpermissionedTokens[i]; + } + for (uint256 i = 0; i < count; i++) { + tokens[i].approve(address(permissionedRouter), type(uint256).max); + tokens[i].approve(address(positionManager), type(uint256).max); + } + return tokens; + } +} diff --git a/test/mocks/MockPermissionedRouter.sol b/test/mocks/MockPermissionedRouter.sol new file mode 100644 index 000000000..7653dc756 --- /dev/null +++ b/test/mocks/MockPermissionedRouter.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PermissionedV4Router} from "../../src/hooks/permissionedPools/PermissionedV4Router.sol"; +import {IPermissionsAdapterFactory} from "../../src/hooks/permissionedPools/interfaces/IPermissionsAdapterFactory.sol"; +import {IPermissionsAdapter} from "../../src/hooks/permissionedPools/interfaces/IPermissionsAdapter.sol"; +import {ReentrancyLock} from "../../src/base/ReentrancyLock.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {IWETH9} from "../../src/interfaces/external/IWETH9.sol"; + +/// @notice Concrete router for testing the abstract PermissionedV4Router. +/// Mirrors the old standalone PermissionedV4Router's execute interface. +contract MockPermissionedRouter is PermissionedV4Router, ReentrancyLock { + IAllowanceTransfer public immutable PERMIT2; + IWETH9 public immutable WETH9; + + error InvalidEthSender(); + error TransactionDeadlinePassed(); + error LengthMismatch(); + error InvalidCommandType(uint256 commandType); + error ExecutionFailed(uint256 commandIndex, bytes message); + error SliceOutOfBounds(); + + uint256 constant COMMAND_V4_SWAP = 0x10; + uint256 constant COMMAND_PERMIT2_PERMIT = 0x0a; + bytes1 internal constant COMMAND_FLAG_ALLOW_REVERT = 0x80; + bytes1 internal constant COMMAND_TYPE_MASK = 0x3f; + + constructor( + IPoolManager poolManager_, + IAllowanceTransfer permit2_, + IPermissionsAdapterFactory permissionsAdapterFactory_, + IWETH9 weth9_ + ) PermissionedV4Router(poolManager_, permissionsAdapterFactory_) { + PERMIT2 = permit2_; + WETH9 = weth9_; + } + + receive() external payable { + if (msg.sender != address(WETH9) && msg.sender != address(poolManager)) revert InvalidEthSender(); + } + + function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable { + if (block.timestamp > deadline) revert TransactionDeadlinePassed(); + execute(commands, inputs); + } + + function execute(bytes calldata commands, bytes[] calldata inputs) public payable isNotLocked { + bool success; + bytes memory output; + uint256 numCommands = commands.length; + if (inputs.length != numCommands) revert LengthMismatch(); + + for (uint256 commandIndex = 0; commandIndex < numCommands; commandIndex++) { + bytes1 command = commands[commandIndex]; + bytes calldata input = inputs[commandIndex]; + (success, output) = _dispatch(command, input); + if (!success && (command & COMMAND_FLAG_ALLOW_REVERT == 0)) { + revert ExecutionFailed({commandIndex: commandIndex, message: output}); + } + } + } + + function msgSender() public view override returns (address) { + return _getLocker(); + } + + function _dispatch(bytes1 commandType, bytes calldata inputs) internal returns (bool success, bytes memory output) { + uint256 command = uint8(commandType & COMMAND_TYPE_MASK); + success = true; + if (command == COMMAND_PERMIT2_PERMIT) { + IAllowanceTransfer.PermitSingle calldata permitSingle; + assembly { + permitSingle := inputs.offset + } + bytes calldata data = _toBytes(inputs, 6); + (success, output) = address(PERMIT2) + .call( + abi.encodeWithSignature( + "permit(address,((address,uint160,uint48,uint48),address,uint256),bytes)", + msgSender(), + permitSingle, + data + ) + ); + } else if (command == COMMAND_V4_SWAP) { + _executeActions(inputs); + } else { + revert InvalidCommandType(command); + } + } + + function _payStandard(Currency currency, address payer, uint256 amount) internal override { + if (payer == address(this)) { + currency.transfer(address(poolManager), amount); + } else { + PERMIT2.transferFrom(payer, address(poolManager), uint160(amount), Currency.unwrap(currency)); + } + } + + function _payPermissionedFromPayer( + address payer, + IPermissionsAdapter permissionsAdapter, + address permissionedToken, + uint256 amount + ) internal override { + PERMIT2.transferFrom(payer, address(permissionsAdapter), uint160(amount), permissionedToken); + permissionsAdapter.wrapToPoolManager(amount); + } + + function _toBytes(bytes calldata _bytes, uint256 _arg) private pure returns (bytes calldata res) { + uint256 length; + uint256 offset; + uint256 relativeOffset; + assembly { + let lengthPtr := add(_bytes.offset, calldataload(add(_bytes.offset, shl(5, _arg)))) + length := calldataload(lengthPtr) + offset := add(lengthPtr, 0x20) + relativeOffset := sub(offset, _bytes.offset) + } + if (_bytes.length < length + relativeOffset) revert SliceOutOfBounds(); + assembly { + res.length := length + res.offset := offset + } + } +} diff --git a/test/shared/Deploy.sol b/test/shared/Deploy.sol index cfb9a215a..a304a1912 100644 --- a/test/shared/Deploy.sol +++ b/test/shared/Deploy.sol @@ -26,6 +26,25 @@ library Deploy { } } + function permissionedPositionManager( + address poolManager, + address permit2, + uint256 unsubscribeGasLimit, + address positionDescriptor_, + address wrappedNative, + address permissionsAdapterFactory, + bytes memory salt + ) internal returns (IPositionManager manager) { + bytes memory args = abi.encode( + poolManager, permit2, unsubscribeGasLimit, positionDescriptor_, wrappedNative, permissionsAdapterFactory + ); + bytes memory initcode = + abi.encodePacked(vm.getCode("PermissionedPositionManager.sol:PermissionedPositionManager"), args); + assembly { + manager := create2(0, add(initcode, 0x20), mload(initcode), salt) + } + } + function stateView(address poolManager, bytes memory salt) internal returns (IStateView stateView_) { bytes memory args = abi.encode(poolManager); bytes memory initcode = abi.encodePacked(vm.getCode("StateView.sol:StateView"), args); @@ -66,4 +85,16 @@ library Deploy { descriptor := create2(0, add(initcode, 0x20), mload(initcode), salt) } } + + function create2(bytes memory initcode, bytes32 salt) internal returns (address contractAddress) { + assembly { + contractAddress := create2(0, add(initcode, 32), mload(initcode), salt) + if iszero(contractAddress) { + let ptr := mload(0x40) + let errorSize := returndatasize() + returndatacopy(ptr, 0, errorSize) + revert(ptr, errorSize) + } + } + } }