diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 000000000..69bc8bc9a --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,57 @@ +name: Release +on: + # manual trigger + workflow_dispatch: + +jobs: + deploy: + name: release + runs-on: + group: npm-deploy + environment: + name: release + permissions: + id-token: write + contents: write + steps: + - name: Load secret + uses: 1password/load-secrets-action@581a835fb51b8e7ec56b71cf2ffddd7e68bb25e0 + with: + # Export loaded secrets as environment variables + export-env: true + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + # You may need to change this to your vault name and secret name + # Refer to it by calling env.NPM_TOKEN + # This token is also limited by IP to ONLY work on the runner + NPM_TOKEN: op://npm-deploy/npm-runner-token/secret + + - name: Checkout + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 + + - name: Setup Node + uses: actions/setup-node@v4.2.0 + with: + node-version: "20.x" + registry-url: "https://registry.npmjs.org" + scope: "@uniswap" + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + + - name: Install dependencies + run: | + git submodule update --init --recursive + + - name: Compile + run: forge build + + - name: Release + env: + NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm set "//registry.npmjs.org/:_authToken" ${{ env.NPM_TOKEN }} + npm publish --provenance --access public diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index c773069bd..b66798fed 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -7,7 +7,7 @@ on: - main schedule: # random HH:MM to avoid a load spike on GitHub Actions at 00:00 - - cron: '35 11 * * *' + - cron: "35 11 * * *" jobs: semgrep: name: semgrep/ci diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bae1823a5..2f434183e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 with: version: stable - + - name: Run tests run: forge test --isolate -vvv env: diff --git a/.gitignore b/.gitignore index f4ebe6d33..83af24df3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ +# if you add a file here, add it to `.npmignore` too cache/ foundry-out/ .vscode/ broadcast/*/*/dry-run/*.json -broadcast/*/*/run-[0-9]*.json \ No newline at end of file +broadcast/*/*/run-[0-9]*.json diff --git a/.npmignore b/.npmignore new file mode 100644 index 000000000..76fe355fd --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +cache/ +.vscode/ +broadcast/*/*/dry-run/*.json +broadcast/*/*/run-[0-9]*.json diff --git a/package.json b/package.json new file mode 100644 index 000000000..4ad9ad6a9 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "@uniswap/v4-periphery", + "version": "1.0.1", + "description": "🦄 Peripheral smart contracts for interacting with Uniswap v4", + "repository": { + "type": "git", + "url": "git+https://github.com/Uniswap/v4-periphery.git" + }, + "license": "MIT", + "bugs": { + "url": "https://uniswap.org/bug-bounty" + }, + "homepage": "https://github.com/Uniswap/v4-periphery#readme", + "publishConfig": { + "access": "public", + "provenance": true + }, + "keywords": [ + "uniswap", + "periphery", + "v4" + ] +} diff --git a/script/DeployHook.s.sol b/script/DeployHook.s.sol new file mode 100644 index 000000000..43f1d7493 --- /dev/null +++ b/script/DeployHook.s.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {HookMiner} from "../src/utils/HookMiner.sol"; + +/// @dev Replace import with your own hook +import {MockCounterHook} from "../test/mocks/MockCounterHook.sol"; + +/// @notice Mines the address and deploys the Counter.sol Hook contract +contract DeployHookScript is Script { + address constant CREATE2_DEPLOYER = address(0x4e59b44847b379578588920cA78FbF26c0B4956C); + + /// @dev Replace with the desired PoolManager on its corresponding chain + IPoolManager constant POOLMANAGER = IPoolManager(address(0xE03A1074c86CFeDd5C142C4F04F1a1536e203543)); + + function setUp() public {} + + function run() public { + // hook contracts must have specific flags encoded in the address + uint160 flags = uint160( + Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG + | Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG + ); + + bytes memory constructorArgs = abi.encode(POOLMANAGER); + + // Mine a salt that will produce a hook address with the correct flags + (address hookAddress, bytes32 salt) = + HookMiner.find(CREATE2_DEPLOYER, flags, type(MockCounterHook).creationCode, constructorArgs); + + // Deploy the hook using CREATE2 + vm.broadcast(); + MockCounterHook counter = new MockCounterHook{salt: salt}(IPoolManager(POOLMANAGER)); + require(address(counter) == hookAddress, "CounterScript: hook address mismatch"); + } +} diff --git a/src/base/ImmutableState.sol b/src/base/ImmutableState.sol index 708a3d281..4b35794f3 100644 --- a/src/base/ImmutableState.sol +++ b/src/base/ImmutableState.sol @@ -10,6 +10,15 @@ contract ImmutableState is IImmutableState { /// @inheritdoc IImmutableState IPoolManager public immutable poolManager; + /// @notice Thrown when the caller is not PoolManager + error NotPoolManager(); + + /// @notice Only allow calls from the PoolManager contract + modifier onlyPoolManager() { + if (msg.sender != address(poolManager)) revert NotPoolManager(); + _; + } + constructor(IPoolManager _poolManager) { poolManager = _poolManager; } diff --git a/src/base/SafeCallback.sol b/src/base/SafeCallback.sol index 45e1c6bf9..b4f993093 100644 --- a/src/base/SafeCallback.sol +++ b/src/base/SafeCallback.sol @@ -8,17 +8,8 @@ import {ImmutableState} from "./ImmutableState.sol"; /// @title Safe Callback /// @notice A contract that only allows the Uniswap v4 PoolManager to call the unlockCallback abstract contract SafeCallback is ImmutableState, IUnlockCallback { - /// @notice Thrown when calling unlockCallback where the caller is not PoolManager - error NotPoolManager(); - constructor(IPoolManager _poolManager) ImmutableState(_poolManager) {} - /// @notice Only allow calls from the PoolManager contract - modifier onlyPoolManager() { - if (msg.sender != address(poolManager)) revert NotPoolManager(); - _; - } - /// @inheritdoc IUnlockCallback /// @dev We force the onlyPoolManager modifier by exposing a virtual function after the onlyPoolManager check. function unlockCallback(bytes calldata data) external onlyPoolManager returns (bytes memory) { diff --git a/src/base/hooks/BaseHook.sol b/src/base/hooks/BaseHook.sol deleted file mode 100644 index 635602a63..000000000 --- a/src/base/hooks/BaseHook.sol +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; -import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; -import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; -import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; -import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; -import {BeforeSwapDelta} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; -import {SafeCallback} from "../SafeCallback.sol"; - -/// @title Base Hook -/// @notice abstract contract for hook implementations -abstract contract BaseHook is IHooks, SafeCallback { - error NotSelf(); - error InvalidPool(); - error LockFailure(); - error HookNotImplemented(); - - constructor(IPoolManager _manager) SafeCallback(_manager) { - validateHookAddress(this); - } - - /// @dev Only this address may call this function - modifier selfOnly() { - if (msg.sender != address(this)) revert NotSelf(); - _; - } - - /// @dev Only pools with hooks set to this contract may call this function - modifier onlyValidPools(IHooks hooks) { - if (hooks != this) revert InvalidPool(); - _; - } - - /// @notice Returns a struct of permissions to signal which hook functions are to be implemented - /// @dev Used at deployment to validate the address correctly represents the expected permissions - function getHookPermissions() public pure virtual returns (Hooks.Permissions memory); - - /// @notice Validates the deployed hook address agrees with the expected permissions of the hook - /// @dev this function is virtual so that we can override it during testing, - /// which allows us to deploy an implementation to any address - /// and then etch the bytecode into the correct address - function validateHookAddress(BaseHook _this) internal pure virtual { - Hooks.validateHookPermissions(_this, getHookPermissions()); - } - - function _unlockCallback(bytes calldata data) internal virtual override returns (bytes memory) { - (bool success, bytes memory returnData) = address(this).call(data); - if (success) return returnData; - if (returnData.length == 0) revert LockFailure(); - // if the call failed, bubble up the reason - assembly ("memory-safe") { - revert(add(returnData, 32), mload(returnData)) - } - } - - /// @inheritdoc IHooks - function beforeInitialize(address, PoolKey calldata, uint160) external virtual returns (bytes4) { - revert HookNotImplemented(); - } - - /// @inheritdoc IHooks - function afterInitialize(address, PoolKey calldata, uint160, int24) external virtual returns (bytes4) { - revert HookNotImplemented(); - } - - /// @inheritdoc IHooks - function beforeAddLiquidity(address, PoolKey calldata, IPoolManager.ModifyLiquidityParams calldata, bytes calldata) - external - virtual - returns (bytes4) - { - revert HookNotImplemented(); - } - - /// @inheritdoc IHooks - function beforeRemoveLiquidity( - address, - PoolKey calldata, - IPoolManager.ModifyLiquidityParams calldata, - bytes calldata - ) external virtual returns (bytes4) { - revert HookNotImplemented(); - } - - /// @inheritdoc IHooks - function afterAddLiquidity( - address, - PoolKey calldata, - IPoolManager.ModifyLiquidityParams calldata, - BalanceDelta, - BalanceDelta, - bytes calldata - ) external virtual returns (bytes4, BalanceDelta) { - revert HookNotImplemented(); - } - - /// @inheritdoc IHooks - function afterRemoveLiquidity( - address, - PoolKey calldata, - IPoolManager.ModifyLiquidityParams calldata, - BalanceDelta, - BalanceDelta, - bytes calldata - ) external virtual returns (bytes4, BalanceDelta) { - revert HookNotImplemented(); - } - - /// @inheritdoc IHooks - function beforeSwap(address, PoolKey calldata, IPoolManager.SwapParams calldata, bytes calldata) - external - virtual - returns (bytes4, BeforeSwapDelta, uint24) - { - revert HookNotImplemented(); - } - - /// @inheritdoc IHooks - function afterSwap(address, PoolKey calldata, IPoolManager.SwapParams calldata, BalanceDelta, bytes calldata) - external - virtual - returns (bytes4, int128) - { - revert HookNotImplemented(); - } - - /// @inheritdoc IHooks - function beforeDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) - external - virtual - returns (bytes4) - { - revert HookNotImplemented(); - } - - /// @inheritdoc IHooks - function afterDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) - external - virtual - returns (bytes4) - { - revert HookNotImplemented(); - } -} diff --git a/src/base/hooks/BaseTokenWrapperHook.sol b/src/base/hooks/BaseTokenWrapperHook.sol new file mode 100644 index 000000000..e70315517 --- /dev/null +++ b/src/base/hooks/BaseTokenWrapperHook.sol @@ -0,0 +1,185 @@ +pragma solidity ^0.8.0; + +import { + toBeforeSwapDelta, BeforeSwapDelta, BeforeSwapDeltaLibrary +} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {BaseHook} from "../../utils/BaseHook.sol"; +import {DeltaResolver} from "../DeltaResolver.sol"; + +/// @title Base Token Wrapper Hook +/// @notice Abstract base contract for implementing token wrapper hooks in Uniswap V4 +/// @dev This contract provides the base functionality for wrapping/unwrapping tokens through V4 pools +/// @dev All liquidity operations are blocked as liquidity is managed through the underlying token wrapper +/// @dev Implementing contracts must provide deposit() and withdraw() functions +abstract contract BaseTokenWrapperHook is BaseHook, DeltaResolver { + using CurrencyLibrary for Currency; + using SafeCast for int256; + using SafeCast for uint256; + + /// @notice Thrown when attempting to add or remove liquidity + /// @dev Liquidity operations are blocked since all liquidity is managed by the token wrapper + error LiquidityNotAllowed(); + + /// @notice Thrown when initializing a pool with invalid tokens + /// @dev Pool must contain exactly one wrapper token and its underlying token + error InvalidPoolToken(); + + /// @notice Thrown when initializing a pool with non-zero fee + /// @dev Fee must be 0 as wrapper pools don't charge fees + error InvalidPoolFee(); + + /// @notice The wrapped token currency (e.g., WETH) + Currency public immutable wrapperCurrency; + + /// @notice The underlying token currency (e.g., ETH) + Currency public immutable underlyingCurrency; + + /// @notice Indicates whether wrapping occurs when swapping from token0 to token1 + /// @dev This is determined by the relative ordering of the wrapper and underlying tokens + /// @dev If true: token0 is underlying (e.g. ETH) and token1 is wrapper (e.g. WETH) + /// @dev If false: token0 is wrapper (e.g. WETH) and token1 is underlying (e.g. ETH) + /// @dev This is set in the constructor based on the token addresses to ensure consistent behavior + bool public immutable wrapZeroForOne; + + /// @notice Creates a new token wrapper hook + /// @param _manager The Uniswap V4 pool manager + /// @param _wrapper The wrapped token currency (e.g., WETH) + /// @param _underlying The underlying token currency (e.g., ETH) + constructor(IPoolManager _manager, Currency _wrapper, Currency _underlying) BaseHook(_manager) { + wrapperCurrency = _wrapper; + underlyingCurrency = _underlying; + wrapZeroForOne = _underlying < _wrapper; + } + + /// @inheritdoc BaseHook + function getHookPermissions() public pure override returns (Hooks.Permissions memory) { + return Hooks.Permissions({ + beforeInitialize: true, + beforeAddLiquidity: true, + beforeSwap: true, + beforeSwapReturnDelta: true, + afterSwap: false, + afterInitialize: false, + beforeRemoveLiquidity: false, + afterAddLiquidity: false, + afterRemoveLiquidity: false, + beforeDonate: false, + afterDonate: false, + afterSwapReturnDelta: false, + afterAddLiquidityReturnDelta: false, + afterRemoveLiquidityReturnDelta: false + }); + } + + /// @notice Validates pool initialization parameters + /// @dev Ensures pool contains wrapper and underlying tokens with zero fee + /// @param poolKey The pool configuration including tokens and fee + /// @return The function selector if validation passes + function _beforeInitialize(address, PoolKey calldata poolKey, uint160) internal view override returns (bytes4) { + // ensure pool tokens are the wrapper currency and underlying currency + bool isValidPair = wrapZeroForOne + ? (poolKey.currency0 == underlyingCurrency && poolKey.currency1 == wrapperCurrency) + : (poolKey.currency0 == wrapperCurrency && poolKey.currency1 == underlyingCurrency); + + if (!isValidPair) revert InvalidPoolToken(); + if (poolKey.fee != 0) revert InvalidPoolFee(); + + return IHooks.beforeInitialize.selector; + } + + /// @notice Prevents liquidity operations on wrapper pools + /// @dev Always reverts as liquidity is managed through the token wrapper + function _beforeAddLiquidity(address, PoolKey calldata, IPoolManager.ModifyLiquidityParams calldata, bytes calldata) + internal + pure + override + returns (bytes4) + { + revert LiquidityNotAllowed(); + } + + /// @notice Handles token wrapping and unwrapping during swaps + /// @dev Processes both exact input (amountSpecified < 0) and exact output (amountSpecified > 0) swaps + /// @param params The swap parameters including direction and amount + /// @return selector The function selector + /// @return swapDelta The input/output token amounts for pool accounting + /// @return lpFeeOverride The fee override (always 0 for wrapper pools) + function _beforeSwap(address, PoolKey calldata, IPoolManager.SwapParams calldata params, bytes calldata) + internal + override + returns (bytes4 selector, BeforeSwapDelta swapDelta, uint24 lpFeeOverride) + { + bool isExactInput = params.amountSpecified < 0; + + if (wrapZeroForOne == params.zeroForOne) { + // we are wrapping + uint256 inputAmount = + isExactInput ? uint256(-params.amountSpecified) : _getWrapInputRequired(uint256(params.amountSpecified)); + _take(underlyingCurrency, address(this), inputAmount); + uint256 wrappedAmount = _deposit(inputAmount); + _settle(wrapperCurrency, address(this), wrappedAmount); + int128 amountUnspecified = + isExactInput ? -wrappedAmount.toInt256().toInt128() : inputAmount.toInt256().toInt128(); + swapDelta = toBeforeSwapDelta(-params.amountSpecified.toInt128(), amountUnspecified); + } else { + // we are unwrapping + uint256 inputAmount = isExactInput + ? uint256(-params.amountSpecified) + : _getUnwrapInputRequired(uint256(params.amountSpecified)); + _take(wrapperCurrency, address(this), inputAmount); + uint256 unwrappedAmount = _withdraw(inputAmount); + _settle(underlyingCurrency, address(this), unwrappedAmount); + int128 amountUnspecified = + isExactInput ? -unwrappedAmount.toInt256().toInt128() : inputAmount.toInt256().toInt128(); + swapDelta = toBeforeSwapDelta(-params.amountSpecified.toInt128(), amountUnspecified); + } + + return (IHooks.beforeSwap.selector, swapDelta, 0); + } + + /// @notice Transfers tokens to the pool manager + /// @param token The token to transfer + /// @param amount The amount to transfer + /// @inheritdoc DeltaResolver + function _pay(Currency token, address, uint256 amount) internal override { + token.transfer(address(poolManager), amount); + } + + /// @notice Deposits underlying tokens to receive wrapper tokens + /// @param underlyingAmount The amount of underlying tokens to deposit + /// @return wrappedAmount The amount of wrapper tokens received + /// @dev Implementing contracts should handle the wrapping operation + /// The base contract will handle settling tokens with the pool manager + function _deposit(uint256 underlyingAmount) internal virtual returns (uint256 wrappedAmount); + + /// @notice Withdraws wrapper tokens to receive underlying tokens + /// @param wrappedAmount The amount of wrapper tokens to withdraw + /// @return underlyingAmount The amount of underlying tokens received + /// @dev Implementing contracts should handle the unwrapping operation + /// The base contract will handle settling tokens with the pool manager + function _withdraw(uint256 wrappedAmount) internal virtual returns (uint256 underlyingAmount); + + /// @notice Calculates underlying tokens needed to receive desired wrapper tokens + /// @param wrappedAmount The desired amount of wrapper tokens + /// @return The required amount of underlying tokens + /// @dev Default implementation assumes 1:1 ratio + /// @dev Override for wrappers with different exchange rates + function _getWrapInputRequired(uint256 wrappedAmount) internal view virtual returns (uint256) { + return wrappedAmount; + } + + /// @notice Calculates wrapper tokens needed to receive desired underlying tokens + /// @param underlyingAmount The desired amount of underlying tokens + /// @return The required amount of wrapper tokens + /// @dev Default implementation assumes 1:1 ratio + /// @dev Override for wrappers with different exchange rates + function _getUnwrapInputRequired(uint256 underlyingAmount) internal view virtual returns (uint256) { + return underlyingAmount; + } +} diff --git a/src/hooks/WETHHook.sol b/src/hooks/WETHHook.sol new file mode 100644 index 000000000..c3faa2f69 --- /dev/null +++ b/src/hooks/WETHHook.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {WETH} from "solmate/src/tokens/WETH.sol"; +import {BaseTokenWrapperHook} from "../base/hooks/BaseTokenWrapperHook.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; + +/// @title Wrapped Ether Hook +/// @notice Hook for wrapping/unwrapping ETH in Uniswap V4 pools +/// @dev Implements 1:1 wrapping/unwrapping of ETH to WETH +contract WETHHook is BaseTokenWrapperHook { + /// @notice The WETH9 contract + WETH public immutable weth; + + error WithdrawFailed(); + + /// @notice Creates a new WETH wrapper hook + /// @param _manager The Uniswap V4 pool manager + /// @param _weth The WETH9 contract address + constructor(IPoolManager _manager, address payable _weth) + BaseTokenWrapperHook( + _manager, + Currency.wrap(_weth), // wrapper token is WETH + CurrencyLibrary.ADDRESS_ZERO // underlying token is ETH (address(0)) + ) + { + weth = WETH(payable(_weth)); + } + + /// @inheritdoc BaseTokenWrapperHook + function _deposit(uint256 underlyingAmount) internal override returns (uint256) { + weth.deposit{value: underlyingAmount}(); + return underlyingAmount; // 1:1 ratio + } + + /// @inheritdoc BaseTokenWrapperHook + function _withdraw(uint256 wrapperAmount) internal override returns (uint256) { + weth.withdraw(wrapperAmount); + return wrapperAmount; // 1:1 ratio + } + + /// @notice Required to receive ETH + receive() external payable {} +} diff --git a/src/utils/BaseHook.sol b/src/utils/BaseHook.sol new file mode 100644 index 000000000..8348b7df9 --- /dev/null +++ b/src/utils/BaseHook.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {BeforeSwapDelta} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; +import {ImmutableState} from "../base/ImmutableState.sol"; + +/// @title Base Hook +/// @notice abstract contract for hook implementations +abstract contract BaseHook is IHooks, ImmutableState { + error HookNotImplemented(); + + constructor(IPoolManager _manager) ImmutableState(_manager) { + validateHookAddress(this); + } + + /// @notice Returns a struct of permissions to signal which hook functions are to be implemented + /// @dev Used at deployment to validate the address correctly represents the expected permissions + function getHookPermissions() public pure virtual returns (Hooks.Permissions memory); + + /// @notice Validates the deployed hook address agrees with the expected permissions of the hook + /// @dev this function is virtual so that we can override it during testing, + /// which allows us to deploy an implementation to any address + /// and then etch the bytecode into the correct address + function validateHookAddress(BaseHook _this) internal pure virtual { + Hooks.validateHookPermissions(_this, getHookPermissions()); + } + + /// @inheritdoc IHooks + function beforeInitialize(address sender, PoolKey calldata key, uint160 sqrtPriceX96) + external + onlyPoolManager + returns (bytes4) + { + return _beforeInitialize(sender, key, sqrtPriceX96); + } + + function _beforeInitialize(address, PoolKey calldata, uint160) internal virtual returns (bytes4) { + revert HookNotImplemented(); + } + + /// @inheritdoc IHooks + function afterInitialize(address sender, PoolKey calldata key, uint160 sqrtPriceX96, int24 tick) + external + onlyPoolManager + returns (bytes4) + { + return _afterInitialize(sender, key, sqrtPriceX96, tick); + } + + function _afterInitialize(address, PoolKey calldata, uint160, int24) internal virtual returns (bytes4) { + revert HookNotImplemented(); + } + + /// @inheritdoc IHooks + function beforeAddLiquidity( + address sender, + PoolKey calldata key, + IPoolManager.ModifyLiquidityParams calldata params, + bytes calldata hookData + ) external onlyPoolManager returns (bytes4) { + return _beforeAddLiquidity(sender, key, params, hookData); + } + + function _beforeAddLiquidity(address, PoolKey calldata, IPoolManager.ModifyLiquidityParams calldata, bytes calldata) + internal + virtual + returns (bytes4) + { + revert HookNotImplemented(); + } + + /// @inheritdoc IHooks + function beforeRemoveLiquidity( + address sender, + PoolKey calldata key, + IPoolManager.ModifyLiquidityParams calldata params, + bytes calldata hookData + ) external onlyPoolManager returns (bytes4) { + return _beforeRemoveLiquidity(sender, key, params, hookData); + } + + function _beforeRemoveLiquidity( + address, + PoolKey calldata, + IPoolManager.ModifyLiquidityParams calldata, + bytes calldata + ) internal virtual returns (bytes4) { + revert HookNotImplemented(); + } + + /// @inheritdoc IHooks + function afterAddLiquidity( + address sender, + PoolKey calldata key, + IPoolManager.ModifyLiquidityParams calldata params, + BalanceDelta delta, + BalanceDelta feesAccrued, + bytes calldata hookData + ) external onlyPoolManager returns (bytes4, BalanceDelta) { + return _afterAddLiquidity(sender, key, params, delta, feesAccrued, hookData); + } + + function _afterAddLiquidity( + address, + PoolKey calldata, + IPoolManager.ModifyLiquidityParams calldata, + BalanceDelta, + BalanceDelta, + bytes calldata + ) internal virtual returns (bytes4, BalanceDelta) { + revert HookNotImplemented(); + } + + /// @inheritdoc IHooks + function afterRemoveLiquidity( + address sender, + PoolKey calldata key, + IPoolManager.ModifyLiquidityParams calldata params, + BalanceDelta delta, + BalanceDelta feesAccrued, + bytes calldata hookData + ) external onlyPoolManager returns (bytes4, BalanceDelta) { + return _afterRemoveLiquidity(sender, key, params, delta, feesAccrued, hookData); + } + + function _afterRemoveLiquidity( + address, + PoolKey calldata, + IPoolManager.ModifyLiquidityParams calldata, + BalanceDelta, + BalanceDelta, + bytes calldata + ) internal virtual returns (bytes4, BalanceDelta) { + revert HookNotImplemented(); + } + + /// @inheritdoc IHooks + function beforeSwap( + address sender, + PoolKey calldata key, + IPoolManager.SwapParams calldata params, + bytes calldata hookData + ) external onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) { + return _beforeSwap(sender, key, params, hookData); + } + + function _beforeSwap(address, PoolKey calldata, IPoolManager.SwapParams calldata, bytes calldata) + internal + virtual + returns (bytes4, BeforeSwapDelta, uint24) + { + revert HookNotImplemented(); + } + + /// @inheritdoc IHooks + function afterSwap( + address sender, + PoolKey calldata key, + IPoolManager.SwapParams calldata params, + BalanceDelta delta, + bytes calldata hookData + ) external onlyPoolManager returns (bytes4, int128) { + return _afterSwap(sender, key, params, delta, hookData); + } + + function _afterSwap(address, PoolKey calldata, IPoolManager.SwapParams calldata, BalanceDelta, bytes calldata) + internal + virtual + returns (bytes4, int128) + { + revert HookNotImplemented(); + } + + /// @inheritdoc IHooks + function beforeDonate( + address sender, + PoolKey calldata key, + uint256 amount0, + uint256 amount1, + bytes calldata hookData + ) external onlyPoolManager returns (bytes4) { + return _beforeDonate(sender, key, amount0, amount1, hookData); + } + + function _beforeDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) + internal + virtual + returns (bytes4) + { + revert HookNotImplemented(); + } + + /// @inheritdoc IHooks + function afterDonate( + address sender, + PoolKey calldata key, + uint256 amount0, + uint256 amount1, + bytes calldata hookData + ) external onlyPoolManager returns (bytes4) { + return _afterDonate(sender, key, amount0, amount1, hookData); + } + + function _afterDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) + internal + virtual + returns (bytes4) + { + revert HookNotImplemented(); + } +} 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/test/SafeCallback.t.sol b/test/SafeCallback.t.sol index 1e7673227..301bd6b75 100644 --- a/test/SafeCallback.t.sol +++ b/test/SafeCallback.t.sol @@ -5,6 +5,7 @@ import "forge-std/Test.sol"; import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; import {SafeCallback} from "../src/base/SafeCallback.sol"; +import {ImmutableState} from "../src/base/ImmutableState.sol"; import {MockSafeCallback} from "./mocks/MockSafeCallback.sol"; contract SafeCallbackTest is Test, Deployers { @@ -26,7 +27,7 @@ contract SafeCallbackTest is Test, Deployers { function test_unlockRevert(address caller, bytes calldata data) public { vm.startPrank(caller); - if (caller != address(manager)) vm.expectRevert(SafeCallback.NotPoolManager.selector); + if (caller != address(manager)) vm.expectRevert(ImmutableState.NotPoolManager.selector); safeCallback.unlockCallback(data); vm.stopPrank(); } diff --git a/test/V4Quoter.t.sol b/test/V4Quoter.t.sol index c32c0ba59..3ddd23885 100644 --- a/test/V4Quoter.t.sol +++ b/test/V4Quoter.t.sol @@ -225,7 +225,7 @@ contract QuoterTest is Test, Deployers { vm.snapshotGasLastCall("Quoter_quoteExactInput_oneHop_startingInitialized"); - assertGt(gasEstimate, 50000); + assertGt(gasEstimate, 40000); assertLt(gasEstimate, 400000); assertEq(amountOut, 198); } diff --git a/test/hooks/WETHHook.t.sol b/test/hooks/WETHHook.t.sol new file mode 100644 index 000000000..e5791798a --- /dev/null +++ b/test/hooks/WETHHook.t.sol @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +import {CustomRevert} from "@uniswap/v4-core/src/libraries/CustomRevert.sol"; +import {WETH} from "solmate/src/tokens/WETH.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "solmate/src/tokens/ERC20.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import {BaseTokenWrapperHook} from "../../src/base/hooks/BaseTokenWrapperHook.sol"; +import {WETHHook} from "../../src/hooks/WETHHook.sol"; + +contract WETHHookTest is Test, Deployers { + using PoolIdLibrary for PoolKey; + using CurrencyLibrary for Currency; + + WETHHook public hook; + WETH public weth; + PoolKey poolKey; + uint160 initSqrtPriceX96; + + // Users + address payable alice = payable(makeAddr("alice")); + address payable bob = payable(makeAddr("bob")); + + event Transfer(address indexed from, address indexed to, uint256 amount); + + function setUp() public { + deployFreshManagerAndRouters(); + weth = new WETH(); + + // Deploy WETH hook + hook = WETHHook( + payable( + address( + uint160( + type(uint160).max & clearAllHookPermissionsMask | Hooks.BEFORE_SWAP_FLAG + | Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG + | Hooks.BEFORE_INITIALIZE_FLAG + ) + ) + ) + ); + deployCodeTo("WETHHook", abi.encode(manager, weth), address(hook)); + + // Create pool key for ETH/WETH + poolKey = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(weth)), + fee: 0, // Must be 0 for wrapper pools + tickSpacing: 60, + hooks: IHooks(address(hook)) + }); + + // Initialize pool at 1:1 price + initSqrtPriceX96 = uint160(TickMath.getSqrtPriceAtTick(0)); + manager.initialize(poolKey, initSqrtPriceX96); + + // Give users some ETH + vm.deal(alice, 100 ether); + vm.deal(bob, 100 ether); + vm.deal(address(this), 200 ether); + (bool success,) = address(weth).call{value: 200 ether}(""); + require(success, "WETH transfer failed"); + weth.transfer(alice, 100 ether); + weth.transfer(bob, 100 ether); + _addUnrelatedLiquidity(); + } + + function test_initialization() public view { + assertEq(address(hook.weth()), address(weth)); + assertEq(Currency.unwrap(hook.wrapperCurrency()), address(weth)); + assertEq(Currency.unwrap(hook.underlyingCurrency()), address(0)); + } + + function test_wrapETH() public { + uint256 wrapAmount = 1 ether; + + uint256 aliceEthBalanceBefore = alice.balance; + uint256 aliceWethBalanceBefore = weth.balanceOf(address(alice)); + uint256 managerEthBalanceBefore = address(manager).balance; + uint256 managerWethBalanceBefore = weth.balanceOf(address(manager)); + + vm.startPrank(alice); + vm.expectEmit(true, true, true, true); + emit Transfer(address(0), address(hook), wrapAmount); + vm.expectEmit(true, true, true, true); + emit Transfer(address(manager), address(alice), wrapAmount); + + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}); + swapRouter.swap{value: wrapAmount}( + poolKey, + IPoolManager.SwapParams({ + zeroForOne: true, // ETH (0) to WETH (1) + amountSpecified: -int256(wrapAmount), + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + testSettings, + "" + ); + + vm.stopPrank(); + + uint256 aliceEthBalanceAfter = alice.balance; + uint256 aliceWethBalanceAfter = weth.balanceOf(address(alice)); + uint256 managerEthBalanceAfter = address(manager).balance; + uint256 managerWethBalanceAfter = weth.balanceOf(address(manager)); + assertEq(aliceEthBalanceBefore - aliceEthBalanceAfter, wrapAmount); + assertEq(aliceWethBalanceAfter - aliceWethBalanceBefore, wrapAmount); + assertEq(managerEthBalanceBefore, managerEthBalanceAfter); + assertEq(managerWethBalanceBefore, managerWethBalanceAfter); + } + + function test_unwrapWETH() public { + uint256 unwrapAmount = 1 ether; + + // Directly deposit WETH to the manager + uint256 aliceEthBalanceBefore = alice.balance; + uint256 aliceWethBalanceBefore = weth.balanceOf(address(alice)); + uint256 managerEthBalanceBefore = address(manager).balance; + uint256 managerWethBalanceBefore = weth.balanceOf(address(manager)); + + vm.startPrank(alice); + weth.approve(address(swapRouter), type(uint256).max); + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}); + + vm.expectEmit(true, true, true, true); + emit Transfer(address(hook), address(0), unwrapAmount); + vm.expectEmit(true, true, true, true); + emit Transfer(address(alice), address(manager), unwrapAmount); + + swapRouter.swap( + poolKey, + IPoolManager.SwapParams({ + zeroForOne: false, // WETH (1) to ETH (0) + amountSpecified: -int256(unwrapAmount), + sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }), + testSettings, + "" + ); + + vm.stopPrank(); + + uint256 aliceEthBalanceAfter = alice.balance; + uint256 aliceWethBalanceAfter = weth.balanceOf(address(alice)); + uint256 managerEthBalanceAfter = address(manager).balance; + uint256 managerWethBalanceAfter = weth.balanceOf(address(manager)); + assertEq(aliceEthBalanceAfter - aliceEthBalanceBefore, unwrapAmount); + assertEq(aliceWethBalanceBefore - aliceWethBalanceAfter, unwrapAmount); + assertEq(managerEthBalanceBefore, managerEthBalanceAfter); + assertEq(managerWethBalanceBefore, managerWethBalanceAfter); + } + + function test_wrapETH_exactOut() public { + uint256 wrapAmount = 1 ether; + + uint256 aliceEthBalanceBefore = alice.balance; + uint256 aliceWethBalanceBefore = weth.balanceOf(address(alice)); + uint256 managerEthBalanceBefore = address(manager).balance; + uint256 managerWethBalanceBefore = weth.balanceOf(address(manager)); + + vm.startPrank(alice); + vm.expectEmit(true, true, true, true); + emit Transfer(address(0), address(hook), wrapAmount); + vm.expectEmit(true, true, true, true); + emit Transfer(address(manager), address(alice), wrapAmount); + + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}); + swapRouter.swap{value: wrapAmount}( + poolKey, + IPoolManager.SwapParams({ + zeroForOne: true, // ETH (0) to WETH (1) + amountSpecified: int256(wrapAmount), // Negative for exact output + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + testSettings, + "" + ); + + vm.stopPrank(); + + uint256 aliceEthBalanceAfter = alice.balance; + uint256 aliceWethBalanceAfter = weth.balanceOf(address(alice)); + uint256 managerEthBalanceAfter = address(manager).balance; + uint256 managerWethBalanceAfter = weth.balanceOf(address(manager)); + assertEq(aliceEthBalanceBefore - aliceEthBalanceAfter, wrapAmount); + assertEq(aliceWethBalanceAfter - aliceWethBalanceBefore, wrapAmount); + assertEq(managerEthBalanceBefore, managerEthBalanceAfter); + assertEq(managerWethBalanceBefore, managerWethBalanceAfter); + } + + function test_unwrapWETH_exactOut() public { + uint256 unwrapAmount = 1 ether; + + uint256 aliceEthBalanceBefore = alice.balance; + uint256 aliceWethBalanceBefore = weth.balanceOf(address(alice)); + uint256 managerEthBalanceBefore = address(manager).balance; + uint256 managerWethBalanceBefore = weth.balanceOf(address(manager)); + + vm.startPrank(alice); + weth.approve(address(swapRouter), type(uint256).max); + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}); + + vm.expectEmit(true, true, true, true); + emit Transfer(address(hook), address(0), unwrapAmount); + vm.expectEmit(true, true, true, true); + emit Transfer(address(alice), address(manager), unwrapAmount); + + swapRouter.swap( + poolKey, + IPoolManager.SwapParams({ + zeroForOne: false, // WETH (1) to ETH (0) + amountSpecified: int256(unwrapAmount), // Negative for exact output + sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }), + testSettings, + "" + ); + + vm.stopPrank(); + + uint256 aliceEthBalanceAfter = alice.balance; + uint256 aliceWethBalanceAfter = weth.balanceOf(address(alice)); + uint256 managerEthBalanceAfter = address(manager).balance; + uint256 managerWethBalanceAfter = weth.balanceOf(address(manager)); + assertEq(aliceEthBalanceAfter - aliceEthBalanceBefore, unwrapAmount); + assertEq(aliceWethBalanceBefore - aliceWethBalanceAfter, unwrapAmount); + assertEq(managerEthBalanceBefore, managerEthBalanceAfter); + assertEq(managerWethBalanceBefore, managerWethBalanceAfter); + } + + function test_revertAddLiquidity() public { + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(hook), + IHooks.beforeAddLiquidity.selector, + abi.encodeWithSelector(BaseTokenWrapperHook.LiquidityNotAllowed.selector), + abi.encodeWithSelector(Hooks.HookCallFailed.selector) + ) + ); + + modifyLiquidityRouter.modifyLiquidity( + poolKey, + IPoolManager.ModifyLiquidityParams({ + tickLower: -120, + tickUpper: 120, + liquidityDelta: 1000e18, + salt: bytes32(0) + }), + "" + ); + } + + function test_revertInvalidPoolInitialization() public { + // Try to initialize with non-zero fee + PoolKey memory invalidKey = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(weth)), + fee: 3000, // Invalid: must be 0 + tickSpacing: 60, + hooks: IHooks(address(hook)) + }); + + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(hook), + IHooks.beforeInitialize.selector, + abi.encodeWithSelector(BaseTokenWrapperHook.InvalidPoolFee.selector), + abi.encodeWithSelector(Hooks.HookCallFailed.selector) + ) + ); + manager.initialize(invalidKey, initSqrtPriceX96); + + // Try to initialize with wrong token pair + MockERC20 randomToken = new MockERC20("Random", "RND", 18); + // sort tokens + (Currency currency0, Currency currency1) = address(randomToken) < address(weth) + ? (Currency.wrap(address(randomToken)), Currency.wrap(address(weth))) + : (Currency.wrap(address(weth)), Currency.wrap(address(randomToken))); + invalidKey = + PoolKey({currency0: currency0, currency1: currency1, fee: 0, tickSpacing: 60, hooks: IHooks(address(hook))}); + + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(hook), + IHooks.beforeInitialize.selector, + abi.encodeWithSelector(BaseTokenWrapperHook.InvalidPoolToken.selector), + abi.encodeWithSelector(Hooks.HookCallFailed.selector) + ) + ); + manager.initialize(invalidKey, initSqrtPriceX96); + } + + // add some unrelated ETH and WETH liquidity that the hook can use + function _addUnrelatedLiquidity() internal { + // Create a hookless pool key for ETH/WETH + PoolKey memory unrelatedPoolKey = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(weth)), + fee: 100, // Must be 0 for wrapper pools + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + manager.initialize(unrelatedPoolKey, uint160(TickMath.getSqrtPriceAtTick(0))); + + vm.deal(address(this), 100 ether); + deal(address(weth), address(this), 100 ether); + weth.approve(address(modifyLiquidityRouter), type(uint256).max); + modifyLiquidityRouter.modifyLiquidity{value: 100 ether}( + unrelatedPoolKey, + IPoolManager.ModifyLiquidityParams({ + tickLower: -120, + tickUpper: 120, + liquidityDelta: 1000e18, + salt: bytes32(0) + }), + "" + ); + } +} diff --git a/test/libraries/BipsLibrary.t.sol b/test/libraries/BipsLibrary.t.sol index 02cc67d71..182358e53 100644 --- a/test/libraries/BipsLibrary.t.sol +++ b/test/libraries/BipsLibrary.t.sol @@ -14,6 +14,7 @@ contract BipsLibraryTest is Test { BLOCK_GAS_LIMIT = block.gaslimit; } + /// forge-config: default.allow_internal_expect_revert = true function test_fuzz_calculatePortion(uint256 amount, uint256 bips) public { amount = bound(amount, 0, uint256(type(uint128).max)); if (bips > BipsLibrary.BPS_DENOMINATOR) { @@ -24,6 +25,7 @@ contract BipsLibraryTest is Test { } } + /// forge-config: default.allow_internal_expect_revert = true function test_fuzz_gasLimit(uint256 bips) public { if (bips > BipsLibrary.BPS_DENOMINATOR) { vm.expectRevert(BipsLibrary.InvalidBips.selector); diff --git a/test/libraries/HookMiner.t.sol b/test/libraries/HookMiner.t.sol new file mode 100644 index 000000000..ce59ba162 --- /dev/null +++ b/test/libraries/HookMiner.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {HookMiner} from "../../src/utils/HookMiner.sol"; +import {MockBlankHook} from "../mocks/MockBlankHook.sol"; + +contract HookMinerTest is Test { + function test_fuzz_hookMiner(uint16 flags, uint256 number) public { + (address addr, bytes32 salt) = HookMiner.find( + address(this), + uint160(flags), + type(MockBlankHook).creationCode, + abi.encode(IPoolManager(address(0)), number, flags) + ); + + MockBlankHook c = new MockBlankHook{salt: salt}(IPoolManager(address(0)), number, flags); + c.forceValidateAddress(); + assertEq(address(c), addr); + assertEq(c.num(), number); + + // address of the contract has the desired flags + assertEq(uint160(address(c)) & HookMiner.FLAG_MASK, flags & HookMiner.FLAG_MASK); + } + + /// @dev not fuzzed because there are certain flags where two unique salts cannot be found in the 160k iterations + function test_hookMiner_addressCollision() public { + uint16 flags = uint16(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG); + uint256 number = 100; + (address addr, bytes32 salt) = HookMiner.find( + address(this), + uint160(flags), + type(MockBlankHook).creationCode, + abi.encode(IPoolManager(address(0)), number, flags) + ); + MockBlankHook c = new MockBlankHook{salt: salt}(IPoolManager(address(0)), number, flags); + c.forceValidateAddress(); + assertEq(address(c), addr); + assertEq(c.num(), number); + + // address of the contract has the desired flags + assertEq(uint160(address(c)) & HookMiner.FLAG_MASK, flags & HookMiner.FLAG_MASK); + + // despite using the same `.find()` parameters, the library skips any addresses with bytecode + (address newAddress, bytes32 otherSalt) = HookMiner.find( + address(this), + uint160(flags), + type(MockBlankHook).creationCode, + abi.encode(IPoolManager(address(0)), number, flags) + ); + + // different salt / address was found + assertNotEq(newAddress, addr); + assertNotEq(otherSalt, salt); + + // second contract deploys successfully with the unique salt + MockBlankHook d = new MockBlankHook{salt: otherSalt}(IPoolManager(address(0)), number, flags); + d.forceValidateAddress(); + assertEq(address(d), newAddress); + assertEq(d.num(), number); + + // address of the contract has the desired flags + assertEq(uint160(address(d)) & HookMiner.FLAG_MASK, flags & HookMiner.FLAG_MASK); + } +} diff --git a/test/mocks/MockBlankHook.sol b/test/mocks/MockBlankHook.sol new file mode 100644 index 000000000..2fba07f6a --- /dev/null +++ b/test/mocks/MockBlankHook.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {BaseHook} from "../../src/utils/BaseHook.sol"; + +contract MockBlankHook is BaseHook { + uint256 public num; + Hooks.Permissions permissions; + + constructor(IPoolManager _poolManager, uint256 _num, uint16 _flags) BaseHook(_poolManager) { + num = _num; + + permissions = Hooks.Permissions({ + beforeInitialize: (_flags & Hooks.BEFORE_INITIALIZE_FLAG) != 0, + afterInitialize: (_flags & Hooks.AFTER_INITIALIZE_FLAG) != 0, + beforeAddLiquidity: (_flags & Hooks.BEFORE_ADD_LIQUIDITY_FLAG) != 0, + afterAddLiquidity: (_flags & Hooks.AFTER_ADD_LIQUIDITY_FLAG) != 0, + beforeRemoveLiquidity: (_flags & Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG) != 0, + afterRemoveLiquidity: (_flags & Hooks.AFTER_REMOVE_LIQUIDITY_FLAG) != 0, + beforeSwap: (_flags & Hooks.BEFORE_SWAP_FLAG) != 0, + afterSwap: (_flags & Hooks.AFTER_SWAP_FLAG) != 0, + beforeDonate: (_flags & Hooks.BEFORE_DONATE_FLAG) != 0, + afterDonate: (_flags & Hooks.AFTER_DONATE_FLAG) != 0, + beforeSwapReturnDelta: (_flags & Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG) != 0, + afterSwapReturnDelta: (_flags & Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG) != 0, + afterAddLiquidityReturnDelta: (_flags & Hooks.AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG) != 0, + afterRemoveLiquidityReturnDelta: (_flags & Hooks.AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG) != 0 + }); + } + + /// @dev Because of C3 Linearization, BaseHook's constructor is executed first + /// do not verify the address until the flags have been set by MockBlankHook's constructor + function validateHookAddress(BaseHook _this) internal pure override {} + + /// @dev cannot override getHookPermissions() since its designated pure, and we cant make it view + /// therefore lets in-line the permissions here + function getHookPermissions() public pure override returns (Hooks.Permissions memory) {} + + function forceValidateAddress() external view { + Hooks.validateHookPermissions(IHooks(address(this)), permissions); + } +} diff --git a/test/mocks/MockCounterHook.sol b/test/mocks/MockCounterHook.sol new file mode 100644 index 000000000..fa8f414e8 --- /dev/null +++ b/test/mocks/MockCounterHook.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {BaseHook} from "../../src/utils/BaseHook.sol"; + +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; + +contract MockCounterHook is BaseHook { + using PoolIdLibrary for PoolKey; + + mapping(PoolId => uint256 count) public beforeSwapCount; + mapping(PoolId => uint256 count) public afterSwapCount; + + mapping(PoolId => uint256 count) public beforeAddLiquidityCount; + mapping(PoolId => uint256 count) public beforeRemoveLiquidityCount; + + constructor(IPoolManager _poolManager) BaseHook(_poolManager) {} + + function getHookPermissions() public pure override returns (Hooks.Permissions memory) { + return Hooks.Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: true, + afterAddLiquidity: false, + beforeRemoveLiquidity: true, + afterRemoveLiquidity: false, + beforeSwap: true, + afterSwap: true, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnDelta: false, + afterSwapReturnDelta: false, + afterAddLiquidityReturnDelta: false, + afterRemoveLiquidityReturnDelta: false + }); + } + + function _beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, bytes calldata) + internal + override + returns (bytes4, BeforeSwapDelta, uint24) + { + beforeSwapCount[key.toId()]++; + return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + } + + function _afterSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, BalanceDelta, bytes calldata) + internal + override + returns (bytes4, int128) + { + afterSwapCount[key.toId()]++; + return (BaseHook.afterSwap.selector, 0); + } + + function _beforeAddLiquidity( + address, + PoolKey calldata key, + IPoolManager.ModifyLiquidityParams calldata, + bytes calldata + ) internal override returns (bytes4) { + beforeAddLiquidityCount[key.toId()]++; + return BaseHook.beforeAddLiquidity.selector; + } + + function _beforeRemoveLiquidity( + address, + PoolKey calldata key, + IPoolManager.ModifyLiquidityParams calldata, + bytes calldata + ) internal override returns (bytes4) { + beforeRemoveLiquidityCount[key.toId()]++; + return BaseHook.beforeRemoveLiquidity.selector; + } +}