diff --git a/.gitmodules b/.gitmodules index 5d08dbd9..2f4a239a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,7 +6,7 @@ url = https://github.com/Uniswap/v4-core [submodule "lib/v4-periphery"] path = lib/v4-periphery - url = https://github.com/Uniswap/v4-periphery + url = https://github.com/uniswap/v4-periphery [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/remappings.txt b/remappings.txt index f43ed510..c16215a4 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,4 +1,4 @@ forge-std/=lib/forge-std/src/ @uniswap/v4-core/=lib/v4-core/ @uniswap/v4-periphery/=lib/v4-periphery/ -@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ \ No newline at end of file +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ diff --git a/src/general/README.adoc b/src/general/README.adoc index fe25f871..86173f3f 100644 --- a/src/general/README.adoc +++ b/src/general/README.adoc @@ -8,9 +8,11 @@ Ready-to-use hooks built on top of the base and fee abstract contracts * {LiquidityPenaltyHook}: Hook resistant to just-in-time liquidity attacks * {AntiSandwichHook}: Hook resistant to sandwich attacks on swaps * {LimitOrderHook}: Hook to enable limit order placing on liquidity pools + * {ReHypothecationHook}: Hook that enables rehypothecation of liquidity positions. == Hooks {{LiquidityPenaltyHook}} {{AntiSandwichHook}} {{LimitOrderHook}} +{{ReHypothecationHook}} diff --git a/src/general/ReHypothecationHook.sol b/src/general/ReHypothecationHook.sol new file mode 100644 index 00000000..f0d99610 --- /dev/null +++ b/src/general/ReHypothecationHook.sol @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Uniswap Hooks (last updated v1.2.0) (src/general/ReHypothecationHook.sol) + +pragma solidity ^0.8.24; + +// External imports +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {Position} from "@uniswap/v4-core/src/libraries/Position.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {TransientStateLibrary} from "@uniswap/v4-core/src/libraries/TransientStateLibrary.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {SwapParams, ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol"; +import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; +// Internal imports +import {BaseHook} from "../base/BaseHook.sol"; +import {CurrencySettler} from "../utils/CurrencySettler.sol"; + +/** + * @dev A Uniswap V4 hook that enables rehypothecation of liquidity positions. + * + * This hook allows users to deposit assets into yield-generating sources (e.g., ERC-4626 vaults) + * while providing liquidity to Uniswap pools Just-in-Time (JIT) during swaps. Assets earn yield + * when idle and are temporarily injected as pool liquidity only when needed for swap execution, + * then immediately withdrawn back to yield sources. + * + * Conceptually, the hook acts as an intermediary that manages: + * - the user-facing ERC20 share token (representing rehypothecated positions), and + * - the underlying relationship between yield sources and pool liquidity. + * + * Key features: + * - Users can deposit assets into yield sources via the hook and receive ERC20 shares + * that represent their rehypothecated liquidity position. + * - The hook dynamically manages pool liquidity based on available yield source assets, + * performing JIT provisioning during swaps. + * - After swaps, assets are deposited back into yield sources to continue earning yield. + * - Supports both ERC20 tokens and native ETH by default. + * + * NOTE: By default, the hook liquidity position is placed in the entire curve range. Override + * the `getTickLower` and `getTickUpper` functions to customize the position. + * + * NOTE: By default, both canonical and rehypothecated liquidity modifications are allowed. Override + * `beforeAddLiquidity` and `beforeRemoveLiquidity` to disable canonical liquidity modifications if desired. + * + * WARNING: This hook relies on the PoolManager singleton token reserves for flash accounting during swaps. + * During `afterSwap`, the hook takes tokens from the PoolManager to settle deltas before users transfer + * their swap tokens. The PoolManager may lack sufficient reserves for illiquid tokens, preventing + * swaps until the PoolManager accumulates enough tokens for these small flash loans. This can be mitigated by + * maintaining some permanent pool liquidity alongside rehypothecated liquidity. + * + * WARNING: This is experimental software and is provided on an "as is" and "as available" basis. + * We do not give any warranties and will not be liable for any losses incurred through any use of + * this code base. + * _Available since v1.1.0_ + */ +abstract contract ReHypothecationHook is BaseHook, ERC20, ReentrancyGuardTransient { + using TransientStateLibrary for IPoolManager; + using StateLibrary for IPoolManager; + using CurrencySettler for Currency; + using SafeCast for *; + using Math for uint256; + using SafeERC20 for IERC20; + + /// @dev The pool key for the hook. Note that the hook supports only one pool key. + PoolKey private _poolKey; + + /// @dev Error thrown when trying to initialize a pool that has already been initialized. + error AlreadyInitialized(); + + /// @dev Error thrown when attempting to interact with a pool that has not been initialized. + error NotInitialized(); + + /// @dev Error thrown when attempting to add or remove liquidity with zero shares. + error ZeroShares(); + + /// @dev Error thrown when the message value doesn't match the expected amount for native ETH deposits. + error InvalidMsgValue(); + + /// @dev Error thrown when the refund fails. + error RefundFailed(); + + /** + * @dev Emitted when a `sender` adds rehypothecated `shares` to the `poolKey` pool, + * transferring `amount0` of `currency0` and `amount1` of `currency1` to the hook. + */ + event ReHypothecatedLiquidityAdded( + address indexed sender, PoolKey indexed poolKey, uint256 shares, uint256 amount0, uint256 amount1 + ); + + /** + * @dev Emitted when a `sender` removes rehypothecated `liquidity` from the `poolKey` pool, + * receiving `amount0` of `currency0` and `amount1` of `currency1` from the hook. + */ + event ReHypothecatedLiquidityRemoved( + address indexed sender, PoolKey indexed poolKey, uint256 shares, uint256 amount0, uint256 amount1 + ); + + /** + * @dev Sets the `PoolManager` address. + */ + constructor(IPoolManager _poolManager) BaseHook(_poolManager) {} + + /** + * @dev Returns the `poolKey` for the hook pool. + */ + function getPoolKey() public view returns (PoolKey memory poolKey) { + return _poolKey; + } + + /** + * @dev Initialize the hook's `poolKey`. The stored key by the hook is unique and + * should not be modified so that it can safely be used across the hook's lifecycle. + * + * NOTE: Native ETH is supported by default, which can be disabled by overriding `_beforeInitialize` with: + * ```solidity + * function _beforeInitialize(address, PoolKey calldata key, uint160) internal override returns (bytes4) { + * if (key.currency0.isAddressZero()) revert UnsupportedCurrency(); + * return super._beforeInitialize(key); + * } + * ``` + */ + function _beforeInitialize(address, PoolKey calldata key, uint160) internal virtual override returns (bytes4) { + if (address(_poolKey.hooks) != address(0)) revert AlreadyInitialized(); + _poolKey = key; + return this.beforeInitialize.selector; + } + + /** + * @dev Adds rehypothecated liquidity to yield sources and mints shares to the caller. + * + * Liquidity is added in the ratio determined by the hook's existing balances in yield sources. + * Assets are deposited into yield sources where they earn returns when idle and can be + * dynamically used as pool liquidity during swaps. + * + * Returns a balance `delta` representing the assets deposited into the hook. + * + * Requirements: + * - Pool must be initialized + * - Sender must have sufficient token balances + * - Sender must have approved the hook to spend the required tokens + */ + function addReHypothecatedLiquidity(uint256 shares) + public + payable + virtual + nonReentrant + returns (BalanceDelta delta) + { + if (address(_poolKey.hooks) == address(0)) revert NotInitialized(); + if (shares == 0) revert ZeroShares(); + + (uint256 amount0, uint256 amount1) = _convertSharesToAmounts(shares); + + _transferFromSenderToHook(_poolKey.currency0, amount0, msg.sender); + _transferFromSenderToHook(_poolKey.currency1, amount1, msg.sender); + + _depositToYieldSource(_poolKey.currency0, amount0); + _depositToYieldSource(_poolKey.currency1, amount1); + + _mint(msg.sender, shares); + + emit ReHypothecatedLiquidityAdded(msg.sender, _poolKey, shares, amount0, amount1); + + return toBalanceDelta(-int256(amount0).toInt128(), -int256(amount1).toInt128()); + } + + /** + * @dev Removes rehypothecated liquidity from yield sources and burns caller's shares. + * + * Liquidity is withdrawn in the ratio determined by the hook's existing balances in yield sources. + * Assets are withdrawn from yield sources where they were generating yield, allowing users to + * exit their rehypothecated position and reclaim their underlying tokens. + * + * Returns a balance `delta` representing the assets withdrawn from the hook. + * + * Requirements: + * - Pool must be initialized + * - Sender must have sufficient shares for the desired liquidity withdrawal + */ + function removeReHypothecatedLiquidity(uint256 shares) public virtual nonReentrant returns (BalanceDelta delta) { + if (address(_poolKey.hooks) == address(0)) revert NotInitialized(); + if (shares == 0) revert ZeroShares(); + + (uint256 amount0, uint256 amount1) = _convertSharesToAmounts(shares); + + _burn(msg.sender, shares); + + _withdrawFromYieldSource(_poolKey.currency0, amount0); + _withdrawFromYieldSource(_poolKey.currency1, amount1); + + _transferFromHookToSender(_poolKey.currency0, amount0, msg.sender); + _transferFromHookToSender(_poolKey.currency1, amount1, msg.sender); + + emit ReHypothecatedLiquidityRemoved(msg.sender, _poolKey, shares, amount0, amount1); + + return toBalanceDelta(int256(amount0).toInt128(), int256(amount1).toInt128()); + } + + /** + * @dev Hook executed before a swap operation to provide liquidity from rehypothecated assets. + * + * Gets the amount of liquidity to be provided from yield sources and temporarily adds it to the pool, + * in a Just-in-Time provision of liquidity. + * + * Note that at this point there are no actual transfers of tokens happening to the pool, instead, + * thanks to the Flash Accounting model, this addition creates a currencyDelta to the hook, which + * must be settled during the `_afterSwap` function before locking the poolManager again. + */ + function _beforeSwap( + address, /* sender */ + PoolKey calldata, /* key */ + SwapParams calldata, /* params */ + bytes calldata /* hookData */ + ) internal virtual override returns (bytes4, BeforeSwapDelta, uint24) { + // Get the liquidity to be used from the amounts currently deposited in the yield sources + uint256 liquidityToUse = _getLiquidityToUse(); + if (liquidityToUse > 0) _modifyLiquidity(liquidityToUse.toInt256()); + + return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + } + + /** + * @dev Hook executed after a swap operation to remove temporary liquidity and rebalance assets. + * + * Removes the liquidity that was temporarily added in `_beforeSwap`, and resolves the hook's + * deltas in each currency in order to neutralize any pending debits or credits. + */ + function _afterSwap( + address, /* sender */ + PoolKey calldata key, + SwapParams calldata, /* params */ + BalanceDelta, /* delta */ + bytes calldata /* hookData */ + ) internal virtual override returns (bytes4, int128) { + // Remove all of the hook owned liquidity from the pool + uint128 liquidity = _getHookPositionLiquidity(); + if (liquidity > 0) { + _modifyLiquidity(-liquidity.toInt256()); + + // Take or settle any pending deltas with the PoolManager + _resolveHookDelta(key.currency0); + _resolveHookDelta(key.currency1); + } + + return (this.afterSwap.selector, 0); + } + + /** + * @dev Takes or settles any pending `currencyDelta` amount with the poolManager, + * neutralizing the Flash Accounting deltas before locking the poolManager again. + */ + function _resolveHookDelta(Currency currency) internal virtual { + int256 currencyDelta = poolManager.currencyDelta(address(this), currency); + if (currencyDelta > 0) { + currency.take(poolManager, address(this), currencyDelta.toUint256(), false); + _depositToYieldSource(currency, currencyDelta.toUint256()); + } + if (currencyDelta < 0) { + _withdrawFromYieldSource(currency, (-currencyDelta).toUint256()); + currency.settle(poolManager, address(this), (-currencyDelta).toUint256(), false); + } + } + + /** + * @dev Preview the amounts of currency0 and currency1 required/obtained for a given amount of shares. + */ + function previewAmountsForShares(uint256 shares) public view virtual returns (uint256 amount0, uint256 amount1) { + return _convertSharesToAmounts(shares); + } + + /** + * @dev Calculates the amounts of currency0 and currency1 required for adding a specific amount of shares. + * + * If the hook has not emitted shares yet, the initial deposit ratio is determined by the current pool price. + * Otherwise, it is determined by ratio of the hook balances in the yield sources. + */ + function _convertSharesToAmounts(uint256 shares) internal view virtual returns (uint256 amount0, uint256 amount1) { + // If the hook has not emitted shares yet, then consider `liquidity == shares` + if (totalSupply() == 0) { + (uint160 currentSqrtPriceX96,,,) = poolManager.getSlot0(_poolKey.toId()); + return LiquidityAmounts.getAmountsForLiquidity( + currentSqrtPriceX96, + TickMath.getSqrtPriceAtTick(getTickLower()), + TickMath.getSqrtPriceAtTick(getTickUpper()), + shares.toUint128() + ); + } + // If the hook has shares, then deposit proportionally to the hook balances in the yield sources + else { + amount0 = _shareToAmount(shares, _poolKey.currency0); + amount1 = _shareToAmount(shares, _poolKey.currency1); + } + } + + /** + * @dev Converts a given `shares` amount to the corresponding `currency` amount. + */ + function _shareToAmount(uint256 shares, Currency currency) internal view virtual returns (uint256 amount) { + uint256 totalAmount = _getAmountInYieldSource(currency); + if (totalAmount == 0) return 0; + return FullMath.mulDiv(shares, totalAmount, totalSupply()); + } + + /** + * @dev Returns the `liquidity` to be provided just-in-time for incoming swaps. + * + * By default, returns the maximum liquidity that can be provided with the current + * balances of the hook in the yield sources. + * + * NOTE: Since liquidity is provided and withdrawn transiently during flash accounting, it + * can be virtually inflated for performing "leveraged liquidity" strategies, which would + * give better pricing to swappers at the cost of the profitability of LP's and increased risks. + */ + function _getLiquidityToUse() internal view virtual returns (uint256) { + (uint160 currentSqrtPriceX96,,,) = poolManager.getSlot0(_poolKey.toId()); + return LiquidityAmounts.getLiquidityForAmounts( + currentSqrtPriceX96, + TickMath.getSqrtPriceAtTick(getTickLower()), + TickMath.getSqrtPriceAtTick(getTickUpper()), + _getAmountInYieldSource(_poolKey.currency0), + _getAmountInYieldSource(_poolKey.currency1) + ); + } + + /** + * @dev Retrieves the current `liquidity` of the hook owned liquidity position in the `_poolKey` pool. + * + * NOTE: Given that just-in-time liquidity provisioning is performed, this function will only return values + * larger than zero between `beforeSwap` and `afterSwap`, where the liquidity is actually inside the pool. + * It will return zero in any other point in the hook lifecycle. For determining the hook balances in any other point, + * use `_getAmountInYieldSource`. + */ + function _getHookPositionLiquidity() internal view virtual returns (uint128 liquidity) { + bytes32 positionKey = Position.calculatePositionKey(address(this), getTickLower(), getTickUpper(), bytes32(0)); + return poolManager.getPositionLiquidity(_poolKey.toId(), positionKey); + } + + /** + * @dev Returns the lower tick boundary for the hook's liquidity position. + * + * Can be overridden to customize the tick boundary. + */ + function getTickLower() public view virtual returns (int24) { + return TickMath.minUsableTick(_poolKey.tickSpacing); + } + + /** + * @dev Returns the upper tick boundary for the hook's liquidity position. + * + * Can be overridden to customize the tick boundary. + */ + function getTickUpper() public view virtual returns (int24) { + return TickMath.maxUsableTick(_poolKey.tickSpacing); + } + + /** + * @dev Modifies the hook's liquidity position in the pool. + * + * Positive liquidityDelta adds liquidity, while negative removes it. + */ + function _modifyLiquidity(int256 liquidityDelta) internal virtual returns (BalanceDelta delta) { + (delta,) = poolManager.modifyLiquidity( + _poolKey, + ModifyLiquidityParams({ + tickLower: getTickLower(), + tickUpper: getTickUpper(), + liquidityDelta: liquidityDelta, + salt: bytes32(0) + }), + "" + ); + } + + /* + * @dev Transfers the `amount` of `currency` from the `sender` to the hook. + */ + function _transferFromSenderToHook(Currency currency, uint256 amount, address sender) + internal + virtual + nonReentrant + { + if (!currency.isAddressZero()) { + IERC20(Currency.unwrap(currency)).safeTransferFrom(sender, address(this), amount); + } else { + if (msg.value < amount) revert InvalidMsgValue(); + if (msg.value > amount) { + (bool success,) = msg.sender.call{value: msg.value - amount}(""); + if (!success) revert RefundFailed(); + } + } + } + + /** + * @dev Transfers the `amount` of `currency` from the hook to the `sender`. + */ + function _transferFromHookToSender(Currency currency, uint256 amount, address sender) internal virtual { + currency.transfer(sender, amount); + } + + /** + * @dev Returns the `yieldSource` address for a given `currency`. + * + * Note: Must be implemented and adapted for the desired type of yield sources, such as + * ERC-4626 Vaults, or any custom DeFi protocol interface, optionally handling native currency. + */ + function getCurrencyYieldSource(Currency currency) public view virtual returns (address yieldSource); + + /** + * @dev Deposits a specified `amount` of `currency` into its corresponding yield source. + * + * Note: Must be implemented and adapted for the desired type of yield sources, such as + * ERC-4626 Vaults, or any custom DeFi protocol interface, optionally handling native currency. + */ + function _depositToYieldSource(Currency currency, uint256 amount) internal virtual; + + /** + * @dev Withdraws a specified `amount` of `currency` from its corresponding yield source. + * + * Note: Must be implemented and adapted for the desired type of yield sources, such as + * ERC-4626 Vaults, or any custom DeFi protocol interface, optionally handling native currency. + */ + function _withdrawFromYieldSource(Currency currency, uint256 amount) internal virtual; + + /** + * @dev Gets the `amount` of `currency` deposited in its corresponding yield source. + * + * Note: Must be implemented and adapted for the desired type of yield sources, such as + * ERC-4626 Vaults, or any custom DeFi protocol interface, optionally handling native currency. + */ + function _getAmountInYieldSource(Currency currency) internal view virtual returns (uint256 amount); + + /** + * Set the hooks permissions, specifically `beforeInitialize`, `beforeSwap`, `afterSwap`. + * @return permissions The permissions for the hook. + */ + function getHookPermissions() public pure virtual override returns (Hooks.Permissions memory permissions) { + return Hooks.Permissions({ + beforeInitialize: true, + afterInitialize: false, + beforeAddLiquidity: false, + afterAddLiquidity: false, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: false, + beforeSwap: true, + afterSwap: true, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnDelta: false, + afterSwapReturnDelta: false, + afterAddLiquidityReturnDelta: false, + afterRemoveLiquidityReturnDelta: false + }); + } + + /// @dev Allows the hook to receive native ETH from the yield sources. + // solhint-disable-next-line + receive() external payable virtual {} +} diff --git a/src/mocks/ReHypothecationERC4626Mock.sol b/src/mocks/ReHypothecationERC4626Mock.sol new file mode 100644 index 00000000..d279d987 --- /dev/null +++ b/src/mocks/ReHypothecationERC4626Mock.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +// External imports +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; + +// Internal imports +import {ReHypothecationHook} from "../general/ReHypothecationHook.sol"; + +/// @title ERC4626Mock +/// @notice A mock implementation of the ERC-4626 yield source. +contract ERC4626YieldSourceMock is ERC4626 { + constructor(IERC20 token) ERC4626(token) ERC20("ERC4626YieldSourceMock", "E4626YS") {} +} + +/// @title ReHypothecationERC4626Mock +/// @notice A mock implementation of the ReHypothecationHook for ERC-4626 yield sources. +contract ReHypothecationERC4626Mock is ReHypothecationHook { + using SafeERC20 for IERC20; + + /// @dev Error thrown when attempting to use an unsupported currency. + error UnsupportedCurrency(); + + address private _yieldSource0; + address private _yieldSource1; + + constructor(IPoolManager poolManager_, address yieldSource0_, address yieldSource1_) + ReHypothecationHook(poolManager_) + ERC20("ReHypothecationMock", "RHM") + { + _yieldSource0 = yieldSource0_; + _yieldSource1 = yieldSource1_; + } + + /// @dev Override to disable native currency, which is not supported by ERC-4626 yield sources. + function _beforeInitialize(address sender, PoolKey calldata key, uint160 sqrtPriceX96) + internal + override + returns (bytes4) + { + if (key.currency0.isAddressZero()) revert UnsupportedCurrency(); + return super._beforeInitialize(sender, key, sqrtPriceX96); + } + + /// @inheritdoc ReHypothecationHook + function getCurrencyYieldSource(Currency currency) public view override returns (address) { + PoolKey memory poolKey = getPoolKey(); + if (currency == poolKey.currency0) return _yieldSource0; + if (currency == poolKey.currency1) return _yieldSource1; + revert UnsupportedCurrency(); + } + + /// @inheritdoc ReHypothecationHook + function _depositToYieldSource(Currency currency, uint256 amount) internal virtual override { + address yieldSource = getCurrencyYieldSource(currency); + if (yieldSource == address(0)) revert UnsupportedCurrency(); + IERC20(Currency.unwrap(currency)).approve(address(yieldSource), amount); + IERC4626(yieldSource).deposit(amount, address(this)); + } + + /// @inheritdoc ReHypothecationHook + function _withdrawFromYieldSource(Currency currency, uint256 amount) internal virtual override { + IERC4626 yieldSource = IERC4626(getCurrencyYieldSource(currency)); + if (address(yieldSource) == address(0)) revert UnsupportedCurrency(); + yieldSource.withdraw(amount, address(this), address(this)); + } + + /// @inheritdoc ReHypothecationHook + function _getAmountInYieldSource(Currency currency) internal view virtual override returns (uint256 amount) { + IERC4626 yieldSource = IERC4626(getCurrencyYieldSource(currency)); + uint256 yieldSourceShares = yieldSource.balanceOf(address(this)); + return yieldSource.convertToAssets(yieldSourceShares); + } + + /// @dev Override to disable native currency, which is not supported by ERC-4626 yield sources. + receive() external payable override { + revert UnsupportedCurrency(); + } + + /// @dev Helpers for testing + function getAmountInYieldSource(Currency currency) public view returns (uint256) { + return _getAmountInYieldSource(currency); + } + + // Exclude from coverage report + function test() public {} +} diff --git a/src/mocks/ReHypothecationNativeMock.sol b/src/mocks/ReHypothecationNativeMock.sol new file mode 100644 index 00000000..47d998b1 --- /dev/null +++ b/src/mocks/ReHypothecationNativeMock.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +// External imports +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +// Internal imports +import {ReHypothecationHook} from "../general/ReHypothecationHook.sol"; +import {ERC4626YieldSourceMock} from "./ReHypothecationERC4626Mock.sol"; + +/// @notice A mock implementation of a native yield source. +/// NOTE: This mock implementation of a native yield source is for testing purposes only. +contract NativeYieldSourceMock is ERC20 { + using Math for *; + + error InvalidAmount(); + + constructor() ERC20("NativeYieldSourceMock", "NYSM") {} + + function totalAssets() public view virtual returns (uint256) { + return address(this).balance; + } + + function convertToAssets(uint256 shares) public view virtual returns (uint256) { + return _convertToAssets(shares); + } + + function convertToShares(uint256 assets) public view virtual returns (uint256) { + return _convertToShares(assets); + } + + function _convertToShares(uint256 assets) internal view virtual returns (uint256) { + return assets.mulDiv(totalSupply(), totalAssets()); + } + + function _convertToAssets(uint256 shares) internal view virtual returns (uint256) { + return shares.mulDiv(totalAssets(), totalSupply()); + } + + function deposit(uint256 amount, address to) public payable { + if (msg.value != amount) revert InvalidAmount(); + _mint(to, amount); + } + + function withdraw(uint256 assets, address to) public payable { + uint256 shares = _convertToShares(assets); + _burn(msg.sender, shares); + payable(to).transfer(assets); + } +} + +/// @title ReHypothecationNativeMock +/// @notice A mock implementation of the ReHypothecationHook for a mixed use case of native ETH and ERC20 tokens. +/// The ERC20 is invested into an ERC-4626 yield source, while the native ETH is invested into a native yield source. +contract ReHypothecationNativeMock is ReHypothecationHook { + using SafeERC20 for IERC20; + + address private _yieldSource0; + address private _yieldSource1; + + /// @dev Error thrown when attempting to use an unsupported currency. + error UnsupportedCurrency(); + + constructor(IPoolManager poolManager_, address yieldSource0_, address yieldSource1_) + ReHypothecationHook(poolManager_) + ERC20("ReHypothecationMock", "RHM") + { + _yieldSource0 = yieldSource0_; + _yieldSource1 = yieldSource1_; + } + + /// @inheritdoc ReHypothecationHook + function getCurrencyYieldSource(Currency currency) public view override returns (address) { + PoolKey memory poolKey = getPoolKey(); + if (currency == poolKey.currency0) return _yieldSource0; + if (currency == poolKey.currency1) return _yieldSource1; + revert UnsupportedCurrency(); + } + + /// @inheritdoc ReHypothecationHook + function _depositToYieldSource(Currency currency, uint256 amount) internal virtual override { + address yieldSource = getCurrencyYieldSource(currency); + if (yieldSource == address(0)) revert UnsupportedCurrency(); + if (currency.isAddressZero()) { + NativeYieldSourceMock(yieldSource).deposit{value: amount}(amount, address(this)); + } else { + IERC20(Currency.unwrap(currency)).approve(address(yieldSource), amount); + NativeYieldSourceMock(yieldSource).deposit(amount, address(this)); + } + } + + /// @inheritdoc ReHypothecationHook + function _withdrawFromYieldSource(Currency currency, uint256 amount) internal virtual override { + address yieldSource = getCurrencyYieldSource(currency); + if (address(yieldSource) == address(0)) revert UnsupportedCurrency(); + if (currency.isAddressZero()) { + NativeYieldSourceMock(yieldSource).withdraw(amount, address(this)); + } else { + ERC4626YieldSourceMock(yieldSource).withdraw(amount, address(this), address(this)); + } + } + + /// @inheritdoc ReHypothecationHook + function _getAmountInYieldSource(Currency currency) internal view virtual override returns (uint256 amount) { + address yieldSource = getCurrencyYieldSource(currency); + uint256 yieldSourceShares = IERC20(yieldSource).balanceOf(address(this)); + return NativeYieldSourceMock(yieldSource).convertToAssets(yieldSourceShares); + } + + /// Override required to handle native ETH + function _transferFromSenderToHook(Currency currency, uint256 amount, address sender) internal virtual override { + if (currency.isAddressZero()) { + if (msg.value < amount) revert InvalidMsgValue(); + if (msg.value > amount) { + (bool success,) = msg.sender.call{value: msg.value - amount}(""); + if (!success) revert RefundFailed(); + } + } else { + super._transferFromSenderToHook(currency, amount, sender); + } + } + + /// @dev Helpers for testing + function getAmountInYieldSource(Currency currency) public view returns (uint256) { + return _getAmountInYieldSource(currency); + } + + // Exclude from coverage report + function test() public {} +} diff --git a/src/utils/README.adoc b/src/utils/README.adoc index 07389fb5..bbc7d514 100644 --- a/src/utils/README.adoc +++ b/src/utils/README.adoc @@ -6,7 +6,9 @@ NOTE: This document is better viewed on the docs page. Libraries and general purpose utilities to help develop hooks. * {CurrencySettler}: Library used to interact with the `PoolManager` to settle any open deltas, with support for ERC-6909 and native currencies. + * {LiquidityMath}: Library with helper functions for liquidity math. == Libraries {{CurrencySettler}} +{{LiquidityMath}} diff --git a/test/general/LiquidityPenaltyHook.t.sol b/test/general/LiquidityPenaltyHook.t.sol index cdf92d09..a72e0efd 100644 --- a/test/general/LiquidityPenaltyHook.t.sol +++ b/test/general/LiquidityPenaltyHook.t.sol @@ -99,7 +99,7 @@ contract LiquidityPenaltyHookTest is HookTest, BalanceDeltaAssertions { // since the ataccker is the only LP, himself is the recipient of the whole donation in the hooked pool BalanceDelta hookFeeDeltaAfterRemoval = calculateFeeDelta(manager, key.toId(), address(modifyLiquidityRouter), -600, 600, bytes32(0)); - assertAproxEqAbs(hookFeeDeltaAfterRemoval, feeDelta, 1, "Hooked: Attacker received donation"); + assertApproxEqAbs(hookFeeDeltaAfterRemoval, feeDelta, 1, "Hooked: Attacker received donation"); // in the unhooked pool, the attacker should have collected the fees during liquidity removal BalanceDelta noHookFeeDeltaAfterRemoval = @@ -278,7 +278,7 @@ contract LiquidityPenaltyHookTest is HookTest, BalanceDeltaAssertions { BalanceDelta hookDeltaBobRemoval = modifyPoolLiquidity(key, -600, 600, -1e14, bobSalt); BalanceDelta noHookDeltaBobRemoval = modifyPoolLiquidity(noHookKey, -600, 600, -1e14, bobSalt); - assertAproxEqAbs( + assertApproxEqAbs( hookDeltaBobRemoval, noHookDeltaBobRemoval + feeDelta + feeDelta, 1, diff --git a/test/general/ReHypothecationHookERC4626.t.sol b/test/general/ReHypothecationHookERC4626.t.sol new file mode 100644 index 00000000..42ea5a49 --- /dev/null +++ b/test/general/ReHypothecationHookERC4626.t.sol @@ -0,0 +1,472 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +// External imports +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +// Internal imports +import {ReHypothecationERC4626Mock, ERC4626YieldSourceMock} from "../../src/mocks/ReHypothecationERC4626Mock.sol"; +import {ReHypothecationHook} from "../../src/general/ReHypothecationHook.sol"; +import {HookTest} from "../../test/utils/HookTest.sol"; +import {BalanceDeltaAssertions} from "../../test/utils/BalanceDeltaAssertions.sol"; +import {CustomRevert} from "@uniswap/v4-core/src/libraries/CustomRevert.sol"; +import {BaseHook} from "../../src/base/BaseHook.sol"; + +contract ReHypothecationHookERC4626Test is HookTest, BalanceDeltaAssertions { + using StateLibrary for IPoolManager; + using SafeCast for *; + using Math for *; + + ReHypothecationERC4626Mock hook; + + IERC4626 yieldSource0; + IERC4626 yieldSource1; + + PoolKey noHookKey; + + address lp1 = makeAddr("lp1"); + address lp2 = makeAddr("lp2"); + + uint24 fee = 1000; // 0.1% + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + yieldSource0 = IERC4626(new ERC4626YieldSourceMock(IERC20(Currency.unwrap(currency0)))); + yieldSource1 = IERC4626(new ERC4626YieldSourceMock(IERC20(Currency.unwrap(currency1)))); + + hook = ReHypothecationERC4626Mock( + payable(address(uint160(Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG))) + ); + deployCodeTo( + "src/mocks/ReHypothecationERC4626Mock.sol:ReHypothecationERC4626Mock", + abi.encode(manager, address(yieldSource0), address(yieldSource1)), + address(hook) + ); + + (key,) = initPool(currency0, currency1, IHooks(address(hook)), fee, SQRT_PRICE_1_1); + (noHookKey,) = initPool(currency0, currency1, IHooks(address(0)), fee, SQRT_PRICE_1_1); + + vm.label(Currency.unwrap(currency0), "currency0"); + vm.label(Currency.unwrap(currency1), "currency1"); + + _fund([address(manager), address(this), lp1, lp2], [currency0, currency1], 1e30); + + _approveCurrencies( + [address(this), lp1, lp2], + [currency0, currency1], + [address(manager), address(hook), address(swapRouter), address(modifyLiquidityRouter)] + ); + } + + function _fund(address[4] memory addresses, Currency[2] memory currencies, uint256 amount) internal { + for (uint256 i = 0; i < addresses.length; i++) { + for (uint256 j = 0; j < currencies.length; j++) { + deal(Currency.unwrap(currencies[j]), addresses[i], amount); + } + } + } + + function _approveCurrencies(address[3] memory approvers, Currency[2] memory currencies, address[4] memory spenders) + internal + { + // make `approvers` approve `currencies` to `spenders` + for (uint256 i = 0; i < approvers.length; i++) { + vm.startPrank(approvers[i]); + for (uint256 j = 0; j < currencies.length; j++) { + for (uint256 k = 0; k < spenders.length; k++) { + IERC20(Currency.unwrap(currencies[j])).approve(spenders[k], type(uint256).max); + } + } + vm.stopPrank(); + } + } + + // -- INITIALIZING -- // + + function test_initialize_already_initialized_reverts() public { + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(hook), // target + bytes4(BaseHook.beforeInitialize.selector), // selector (beforeInitialize) + abi.encodeWithSelector(ReHypothecationHook.AlreadyInitialized.selector), // reason + hex"a9e35b2f" // details + ) + ); + initPool(currency0, currency1, IHooks(address(hook)), fee, SQRT_PRICE_1_1); + } + + function test_initialize_native_currency_reverts() public { + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(hook), + IHooks.beforeInitialize.selector, + abi.encodeWithSelector(ReHypothecationERC4626Mock.UnsupportedCurrency.selector), + abi.encodeWithSelector(Hooks.HookCallFailed.selector), + hex"a9e35b2f" + ) + ); + initPool(Currency.wrap(address(0)), currency1, IHooks(address(hook)), fee, SQRT_PRICE_1_1); + } + + // -- ADDING -- // + + function test_add_uninitialized_reverts() public { + uint160 hookFlags = uint160(Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG); + ReHypothecationERC4626Mock newHook = ReHypothecationERC4626Mock( + payable(address(hookFlags + 0x10000000000000000000000000000000)) // generate a different address + ); + deployCodeTo( + "src/mocks/ReHypothecationERC4626Mock.sol:ReHypothecationERC4626Mock", + abi.encode(manager, address(yieldSource0), address(yieldSource1)), + address(newHook) + ); + vm.expectRevert(ReHypothecationHook.NotInitialized.selector); + newHook.addReHypothecatedLiquidity(1e15); + } + + function test_add_zero_reverts() public { + vm.expectRevert(ReHypothecationHook.ZeroShares.selector); + hook.addReHypothecatedLiquidity(0); + } + + function testFuzz_add_singleLP(uint128 shares) public { + shares = uint128(bound(shares, 1e12, 1e20)); + + uint256 lpAmount0Before = IERC20(Currency.unwrap(currency0)).balanceOf(address(this)); + uint256 lpAmount1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + + uint256 amount0InYieldSource0Before = hook.getAmountInYieldSource(currency0); + uint256 amount1InYieldSource1Before = hook.getAmountInYieldSource(currency1); + + (uint256 previewedAmount0, uint256 previewedAmount1) = hook.previewAmountsForShares(shares); + + BalanceDelta delta = hook.addReHypothecatedLiquidity(shares); + + assertEq((-delta.amount0()).toUint256(), previewedAmount0, "Delta.amount0() != amount0"); + assertEq((-delta.amount1()).toUint256(), previewedAmount1, "Delta.amount1() != amount1"); + + uint256 lpAmount0After = IERC20(Currency.unwrap(currency0)).balanceOf(address(this)); + uint256 lpAmount1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + + uint256 amount0InYieldSource0After = hook.getAmountInYieldSource(currency0); + uint256 amount1InYieldSource1After = hook.getAmountInYieldSource(currency1); + + assertEq(lpAmount0After, lpAmount0Before - previewedAmount0, "lpAmount0After != lpAmount0Before - amount0"); + assertEq(lpAmount1After, lpAmount1Before - previewedAmount1, "lpAmount1After != lpAmount1Before - amount1"); + + assertEq( + amount0InYieldSource0After, + amount0InYieldSource0Before + previewedAmount0, + "Amount0InYieldSource0After != Amount0InYieldSource0Before + Amount0" + ); + assertEq( + amount1InYieldSource1After, + amount1InYieldSource1Before + previewedAmount1, + "amount1InYieldSource1After != amount1InYieldSource1Before + amount1" + ); + + uint256 obtainedShares = hook.balanceOf(address(this)); + assertEq(obtainedShares, hook.totalSupply(), "obtained shares != total supply"); + } + + function test_add_multipleLP() public { + uint128 shareslp1 = 1e18; + uint128 shareslp2 = 1e18; + + vm.prank(lp1); + BalanceDelta addDeltalp1 = hook.addReHypothecatedLiquidity(shareslp1); + + vm.prank(lp2); + BalanceDelta addDeltalp2 = hook.addReHypothecatedLiquidity(shareslp2); + + // both must have paid the same amount of assets + assertEq(addDeltalp1, addDeltalp2); + + // both must have received the same amount of assets + assertEq(hook.balanceOf(lp1), hook.balanceOf(lp2)); + + // total supply should be the sum of the shares + assertEq(hook.totalSupply(), shareslp1 + shareslp2); + } + + function test_add_swap_add_multipleLP() public { + // both lps want equal amount of shares + uint128 shareslp1 = 1e18; + uint128 shareslp2 = 1e18; + + vm.prank(lp1); + BalanceDelta addDeltalp1 = hook.addReHypothecatedLiquidity(shareslp1); + + swap(key, true, 1e15, ZERO_BYTES); + // perform another swap to rebalance the pool + swap(key, false, 1e15 + 1e10, ZERO_BYTES); + + vm.prank(lp2); + BalanceDelta addDeltalp2 = hook.addReHypothecatedLiquidity(shareslp2); + + // both must have received the same amount of shares + assertEq(hook.balanceOf(lp1), hook.balanceOf(lp2)); + + // lp2 must have deposited more assets than lp1 to achieve the same shares + assertGt(-addDeltalp2.amount0(), -addDeltalp1.amount0()); + assertGt(-addDeltalp2.amount1(), -addDeltalp1.amount1()); + + // total supply should be the sum of the shares + assertEq(hook.totalSupply(), shareslp1 + shareslp2); + } + + function test_add_yieldsGrowth_add_multipleLP() public { + uint128 shareslp1 = 1e18; + uint128 shareslp2 = 1e18; + + vm.prank(lp1); + BalanceDelta addDeltalp1 = hook.addReHypothecatedLiquidity(shareslp1); + + uint256 amount0InYieldSource = hook.getAmountInYieldSource(currency0); + uint256 amount1InYieldSource = hook.getAmountInYieldSource(currency1); + + // yield1 grows by 10% + currency0.transfer(address(yieldSource0), amount0InYieldSource * 10 / 100); + // yield2 grows by 20% + currency1.transfer(address(yieldSource1), amount1InYieldSource * 20 / 100); + + BalanceDelta addDeltalp2 = hook.addReHypothecatedLiquidity(shareslp2); + + // in order to obtain the same shares, lp2 must pay 10% more currency0 and 20% more currency1 + assertApproxEqAbs(-addDeltalp2.amount0(), -addDeltalp1.amount0() * 110 / 100, 1); + assertApproxEqAbs(-addDeltalp2.amount1(), -addDeltalp1.amount1() * 120 / 100, 1); + + // total supply should be the sum of the shares + assertEq(hook.totalSupply(), shareslp1 + shareslp2); + } + + // -- REMOVING -- // + + function test_remove_uninitialized_reverts() public { + uint160 hookFlags = uint160(Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG); + ReHypothecationERC4626Mock newHook = ReHypothecationERC4626Mock( + payable(address(hookFlags + 0x10000000000000000000000000000000)) // generate a different address + ); + deployCodeTo( + "src/mocks/ReHypothecationERC4626Mock.sol:ReHypothecationERC4626Mock", + abi.encode(manager, address(yieldSource0), address(yieldSource1)), + address(newHook) + ); + vm.expectRevert(ReHypothecationHook.NotInitialized.selector); + newHook.removeReHypothecatedLiquidity(1e15); + } + + function test_remove_zero_reverts() public { + vm.expectRevert(ReHypothecationHook.ZeroShares.selector); + hook.removeReHypothecatedLiquidity(0); + } + + function testFuzz_remove_singleLP(uint128 shares) public { + shares = uint128(bound(shares, 1e12, 1e20)); + + BalanceDelta addDelta = hook.addReHypothecatedLiquidity(shares); + + uint256 lpAmount0Before = IERC20(Currency.unwrap(currency0)).balanceOf(address(this)); + uint256 lpAmount1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + + uint256 amount0InYieldSource0Before = hook.getAmountInYieldSource(currency0); + uint256 amount1InYieldSource1Before = hook.getAmountInYieldSource(currency1); + + (uint256 amount0, uint256 amount1) = hook.previewAmountsForShares(shares); + + BalanceDelta removeDelta = hook.removeReHypothecatedLiquidity(shares); + + assertEq(-addDelta.amount0(), removeDelta.amount0()); + assertEq(-addDelta.amount1(), removeDelta.amount1()); + + assertEq(removeDelta.amount0().toUint256(), amount0, "Delta.amount0() != amount0"); + assertEq(removeDelta.amount1().toUint256(), amount1, "Delta.amount1() != amount1"); + + uint256 lpAmount0After = IERC20(Currency.unwrap(currency0)).balanceOf(address(this)); + uint256 lpAmount1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this)); + + uint256 amount0InYieldSource0After = hook.getAmountInYieldSource(currency0); + uint256 amount1InYieldSource1After = hook.getAmountInYieldSource(currency1); + + assertEq(lpAmount0After, lpAmount0Before + amount0, "lpAmount0After != lpAmount0Before + amount0"); + assertEq(lpAmount1After, lpAmount1Before + amount1, "lpAmount1After != lpAmount1Before + amount1"); + + assertEq( + amount0InYieldSource0After, + amount0InYieldSource0Before - amount0, + "amount0InYieldSource0After != amount0InYieldSource0Before + amount0" + ); + assertEq( + amount1InYieldSource1After, + amount1InYieldSource1Before - amount1, + "amount1InYieldSource1After != amount1InYieldSource1Before + amount1" + ); + + assertEq(hook.balanceOf(address(this)), 0, "Held shares != 0"); + assertEq(hook.totalSupply(), 0, "total shares != 0"); + } + + function test_remove_multipleLP() public { + uint128 shareslp1 = 1e18; + uint128 shareslp2 = 1e18; + + vm.prank(lp1); + hook.addReHypothecatedLiquidity(shareslp1); + vm.prank(lp2); + hook.addReHypothecatedLiquidity(shareslp2); + + vm.prank(lp1); + BalanceDelta removeDeltalp1 = hook.removeReHypothecatedLiquidity(shareslp1); + vm.prank(lp2); + BalanceDelta removeDeltalp2 = hook.removeReHypothecatedLiquidity(shareslp2); + + // both must have removed the same amount of assets + assertEq(removeDeltalp1, removeDeltalp2); + + // both must have burned their shares + assertEq(hook.balanceOf(lp1), 0); + assertEq(hook.balanceOf(lp2), 0); + + // total supply should be 0 + assertEq(hook.totalSupply(), 0); + } + + function test_swap_remove_remove_multipleLP() public { + uint128 shareslp1 = 1e18; + uint128 shareslp2 = 1e18; + + vm.prank(lp1); + hook.addReHypothecatedLiquidity(shareslp1); + vm.prank(lp2); + hook.addReHypothecatedLiquidity(shareslp2); + + swap(key, true, 1e15, ZERO_BYTES); + + vm.prank(lp1); + BalanceDelta removeDeltalp1 = hook.removeReHypothecatedLiquidity(shareslp1); + vm.prank(lp2); + BalanceDelta removeDeltalp2 = hook.removeReHypothecatedLiquidity(shareslp2); + + // both must have removed the same amount of assets + assertApproxEqAbs(removeDeltalp1, removeDeltalp2, 1); + + // both must have burned their shares + assertEq(hook.balanceOf(lp1), 0); + assertEq(hook.balanceOf(lp2), 0); + + // total supply should be 0 + assertEq(hook.totalSupply(), 0); + } + + function test_remove_swap_remove_multipleLP() public { + uint128 shareslp1 = 1e18; + uint128 shareslp2 = 1e18; + + vm.prank(lp1); + hook.addReHypothecatedLiquidity(shareslp1); + vm.prank(lp2); + hook.addReHypothecatedLiquidity(shareslp2); + + vm.prank(lp1); + BalanceDelta removeDeltalp1 = hook.removeReHypothecatedLiquidity(shareslp1); + + swap(key, true, 1e15, ZERO_BYTES); + swap(key, false, 1e15 + 1e10, ZERO_BYTES); + + vm.prank(lp2); + BalanceDelta removeDeltalp2 = hook.removeReHypothecatedLiquidity(shareslp2); + + // lp2 must have removed more assets, since the fees from the swap belongs to him + assertGt(removeDeltalp2.amount0(), removeDeltalp1.amount0()); + assertGt(removeDeltalp2.amount1(), removeDeltalp1.amount1()); + + // both must have burned their shares + assertEq(hook.balanceOf(lp1), 0); + assertEq(hook.balanceOf(lp2), 0); + + // total supply should be 0 + assertEq(hook.totalSupply(), 0); + } + + function test_remove_yieldsGrowth_remove_multipleLP() public { + uint128 shareslp1 = 1e18; + uint128 shareslp2 = 1e18; + + vm.prank(lp1); + hook.addReHypothecatedLiquidity(shareslp1); + vm.prank(lp2); + hook.addReHypothecatedLiquidity(shareslp2); + + // lp1 removes + vm.prank(lp1); + BalanceDelta removeDeltalp1 = hook.removeReHypothecatedLiquidity(shareslp1); + + uint256 amount0InYieldSource = hook.getAmountInYieldSource(currency0); + uint256 amount1InYieldSource = hook.getAmountInYieldSource(currency1); + + // yield1 grows by 10% + currency0.transfer(address(yieldSource0), amount0InYieldSource * 10 / 100); + // yield2 grows by 20% + currency1.transfer(address(yieldSource1), amount1InYieldSource * 20 / 100); + + // lp2 removes + vm.prank(lp2); + BalanceDelta removeDeltalp2 = hook.removeReHypothecatedLiquidity(shareslp2); + + // lp2 must have removed more assets, since the fees from the yield growth belongs to him + assertGt(removeDeltalp2.amount0(), removeDeltalp1.amount0()); + assertGt(removeDeltalp2.amount1(), removeDeltalp1.amount1()); + + // both must have burned their shares + assertEq(hook.balanceOf(lp1), 0); + assertEq(hook.balanceOf(lp2), 0); + + // total supply should be 0 + assertEq(hook.totalSupply(), 0); + } + + // -- differential -- // + + function testFuzz_differential_add_swap_remove_SingleLP(uint256 shares, int256 amountToSwap) public { + shares = uint256(bound(shares, 1e12, 1e26)); // add from 0.000001 to 100M shares + amountToSwap = int256(bound(amountToSwap, 1e10, 1e24)); // swap from 0.00000001 to 1M tokens + // assume the swap is less than half of the added liquidity + vm.assume(amountToSwap * 2 < int256(shares)); + + // -- Add liquidity -- + // Unhooked + BalanceDelta noHookAddDelta = + modifyPoolLiquidity(noHookKey, hook.getTickLower(), hook.getTickUpper(), int256(uint256(shares)), 0); + // Hooked + BalanceDelta hookedAddDelta = hook.addReHypothecatedLiquidity(shares); + assertApproxEqAbs(hookedAddDelta, noHookAddDelta, 10, "hookedAddDelta !~= noHookAddDelta"); + + // -- Swap -- + // Unhooked + BalanceDelta noHookSwapDelta = swap(noHookKey, true, amountToSwap, ZERO_BYTES); + // Hooked + BalanceDelta hookedSwapDelta = swap(key, true, amountToSwap, ZERO_BYTES); + assertApproxEqAbs(hookedSwapDelta, noHookSwapDelta, 10, "hookedSwapDelta !~= noHookSwapDelta"); + + // -- Remove liquidity -- + // Unhooked + BalanceDelta noHookRemoveDelta = + modifyPoolLiquidity(noHookKey, hook.getTickLower(), hook.getTickUpper(), -int256(uint256(shares)), 0); + // Hooked + BalanceDelta hookedRemoveDelta = hook.removeReHypothecatedLiquidity(shares); + assertApproxEqAbs(hookedRemoveDelta, noHookRemoveDelta, 2, "hookedRemoveDelta !~= noHookRemoveDelta"); + } +} diff --git a/test/general/ReHypothecationHookNative.t.sol b/test/general/ReHypothecationHookNative.t.sol new file mode 100644 index 00000000..a4f0bfb2 --- /dev/null +++ b/test/general/ReHypothecationHookNative.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +// External imports +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +// Internal imports +import {ReHypothecationNativeMock, NativeYieldSourceMock} from "../../src/mocks/ReHypothecationNativeMock.sol"; +import {ERC4626YieldSourceMock} from "../../src/mocks/ReHypothecationERC4626Mock.sol"; +import {HookTest} from "../../test/utils/HookTest.sol"; +import {BalanceDeltaAssertions} from "../../test/utils/BalanceDeltaAssertions.sol"; + +contract ReHypothecationHookNativeTest is HookTest, BalanceDeltaAssertions { + using StateLibrary for IPoolManager; + using SafeCast for *; + using Math for *; + + ReHypothecationNativeMock hook; + + NativeYieldSourceMock yieldSource0; + ERC4626YieldSourceMock yieldSource1; + + PoolKey noHookKey; + + address lp1 = makeAddr("lp1"); + address lp2 = makeAddr("lp2"); + + uint24 fee = 1000; // 0.1% + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + yieldSource0 = new NativeYieldSourceMock(); + yieldSource1 = new ERC4626YieldSourceMock(IERC20(Currency.unwrap(currency1))); + + hook = ReHypothecationNativeMock( + payable(address(uint160(Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG))) + ); + deployCodeTo( + "src/mocks/ReHypothecationNativeMock.sol:ReHypothecationNativeMock", + abi.encode(manager, address(yieldSource0), address(yieldSource1)), + address(hook) + ); + + (key,) = initPool(Currency.wrap(address(0)), currency1, IHooks(address(hook)), fee, SQRT_PRICE_1_1); + (noHookKey,) = initPool(Currency.wrap(address(0)), currency1, IHooks(address(0)), fee, SQRT_PRICE_1_1); + + vm.label(address(0), "currency0"); + vm.label(Currency.unwrap(currency1), "currency1"); + + _fundNative([address(manager), address(this), lp1, lp2], 1e30); + + _fund([address(manager), address(this), lp1, lp2], [currency1], 1e30); + + _approveCurrencies( + [address(this), lp1, lp2], + [currency1], + [address(manager), address(hook), address(swapRouter), address(modifyLiquidityRouter)] + ); + } + + function _fundNative(address[4] memory addresses, uint256 amount) internal { + for (uint256 i = 0; i < addresses.length; i++) { + deal(addresses[i], amount); + } + } + + function _fund(address[4] memory addresses, Currency[1] memory currencies, uint256 amount) internal { + for (uint256 i = 0; i < addresses.length; i++) { + for (uint256 j = 0; j < currencies.length; j++) { + deal(Currency.unwrap(currencies[j]), addresses[i], amount); + } + } + } + + function _approveCurrencies(address[3] memory approvers, Currency[1] memory currencies, address[4] memory spenders) + internal + { + for (uint256 i = 0; i < approvers.length; i++) { + vm.startPrank(approvers[i]); + for (uint256 j = 0; j < currencies.length; j++) { + for (uint256 k = 0; k < spenders.length; k++) { + IERC20(Currency.unwrap(currencies[j])).approve(spenders[k], type(uint256).max); + } + } + vm.stopPrank(); + } + } + + // -- INITIALIZING -- // + + function test_initialize_native_currency_supported() public { + // Native currency (address(0)) should be supported in the native mock + uint160 hookFlags = uint160(Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG); + ReHypothecationNativeMock newHook = ReHypothecationNativeMock( + payable(address(hookFlags + 0x10000000000000000000000000000000)) // generate a different address + ); + deployCodeTo( + "src/mocks/ReHypothecationNativeMock.sol:ReHypothecationNativeMock", + abi.encode(manager, address(yieldSource0), address(yieldSource1)), + address(newHook) + ); + (PoolKey memory nativeKey,) = + initPool(Currency.wrap(address(0)), currency1, IHooks(address(newHook)), fee, SQRT_PRICE_1_1); + assertTrue(nativeKey.currency0.isAddressZero()); + } + + // -- DIFFERENTIAL TESTING -- // + + function test_differential_add_swap_remove() public { + uint256 shares = 1e18; + int256 amountToSwap = -1e14; // exact input + + // Add liquidity + BalanceDelta noHookAddDelta = modifyLiquidityRouter.modifyLiquidity{value: 1e18}( + noHookKey, + ModifyLiquidityParams({ + tickLower: hook.getTickLower(), + tickUpper: hook.getTickUpper(), + liquidityDelta: int256(shares), + salt: 0 + }), + "" + ); + BalanceDelta hookedAddDelta = hook.addReHypothecatedLiquidity{value: 1e18}(shares); + assertApproxEqAbs(hookedAddDelta, noHookAddDelta, 10, "hookedAddDelta !~= noHookAddDelta"); + + // Swap + BalanceDelta noHookSwapDelta = + swapNativeInput(noHookKey, true, amountToSwap, ZERO_BYTES, (-amountToSwap).toUint256()); + BalanceDelta hookedSwapDelta = swapNativeInput(key, true, amountToSwap, ZERO_BYTES, (-amountToSwap).toUint256()); + assertApproxEqAbs(hookedSwapDelta, noHookSwapDelta, 10, "hookedSwapDelta !~= noHookSwapDelta"); + + // // Remove liquidity + BalanceDelta noHookRemoveDelta = + modifyPoolLiquidity(noHookKey, hook.getTickLower(), hook.getTickUpper(), -int256(shares), 0); + BalanceDelta hookedRemoveDelta = hook.removeReHypothecatedLiquidity(shares); + assertApproxEqAbs(hookedRemoveDelta, noHookRemoveDelta, 2, "hookedRemoveDelta !~= noHookRemoveDelta"); + } +} diff --git a/test/utils/BalanceDeltaAssertions.sol b/test/utils/BalanceDeltaAssertions.sol index 4bec428d..dc9366ad 100644 --- a/test/utils/BalanceDeltaAssertions.sol +++ b/test/utils/BalanceDeltaAssertions.sol @@ -20,13 +20,13 @@ contract BalanceDeltaAssertions is Test { } // @dev Asserts that `delta1` is approximately equal to `delta2` for both amount0 and amount1 - function assertAproxEqAbs(BalanceDelta delta1, BalanceDelta delta2, uint256 absTolerance) internal pure { + function assertApproxEqAbs(BalanceDelta delta1, BalanceDelta delta2, uint256 absTolerance) internal pure { assertApproxEqAbs(BalanceDeltaLibrary.amount1(delta1), BalanceDeltaLibrary.amount1(delta2), absTolerance); assertApproxEqAbs(BalanceDeltaLibrary.amount0(delta1), BalanceDeltaLibrary.amount0(delta2), absTolerance); } // @dev Asserts that `delta1` is approximately equal to `delta2` for both amount0 and amount1 with a custom error message - function assertAproxEqAbs(BalanceDelta delta1, BalanceDelta delta2, uint256 absTolerance, string memory err) + function assertApproxEqAbs(BalanceDelta delta1, BalanceDelta delta2, uint256 absTolerance, string memory err) internal pure {