diff --git a/.forge-snapshots/FullRangeAddInitialLiquidity.snap b/.forge-snapshots/FullRangeAddInitialLiquidity.snap new file mode 100644 index 00000000..1f949e10 --- /dev/null +++ b/.forge-snapshots/FullRangeAddInitialLiquidity.snap @@ -0,0 +1 @@ +412012 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeAddLiquidity.snap b/.forge-snapshots/FullRangeAddLiquidity.snap new file mode 100644 index 00000000..2e524b49 --- /dev/null +++ b/.forge-snapshots/FullRangeAddLiquidity.snap @@ -0,0 +1 @@ +206278 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeFirstSwap.snap b/.forge-snapshots/FullRangeFirstSwap.snap new file mode 100644 index 00000000..57d530e3 --- /dev/null +++ b/.forge-snapshots/FullRangeFirstSwap.snap @@ -0,0 +1 @@ +152057 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeInitialize.snap b/.forge-snapshots/FullRangeInitialize.snap new file mode 100644 index 00000000..b1df71e8 --- /dev/null +++ b/.forge-snapshots/FullRangeInitialize.snap @@ -0,0 +1 @@ +878152 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeRemoveLiquidity.snap b/.forge-snapshots/FullRangeRemoveLiquidity.snap new file mode 100644 index 00000000..35f89942 --- /dev/null +++ b/.forge-snapshots/FullRangeRemoveLiquidity.snap @@ -0,0 +1 @@ +199416 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap b/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap new file mode 100644 index 00000000..a6da1a0d --- /dev/null +++ b/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap @@ -0,0 +1 @@ +375677 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeSecondSwap.snap b/.forge-snapshots/FullRangeSecondSwap.snap new file mode 100644 index 00000000..55489485 --- /dev/null +++ b/.forge-snapshots/FullRangeSecondSwap.snap @@ -0,0 +1 @@ +109596 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeSwap.snap b/.forge-snapshots/FullRangeSwap.snap new file mode 100644 index 00000000..14527430 --- /dev/null +++ b/.forge-snapshots/FullRangeSwap.snap @@ -0,0 +1 @@ +150064 \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index ea155c45..9e4b995c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "lib/v4-core"] path = lib/v4-core url = git@github.com:Uniswap/v4-core.git +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/transmissions11/solmate diff --git a/contracts/hooks/examples/FullRange.sol b/contracts/hooks/examples/FullRange.sol new file mode 100644 index 00000000..4ca7904c --- /dev/null +++ b/contracts/hooks/examples/FullRange.sol @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import {IPoolManager} from "@uniswap/v4-core/contracts/interfaces/IPoolManager.sol"; +import {PoolManager} from "@uniswap/v4-core/contracts/PoolManager.sol"; +import {Hooks} from "@uniswap/v4-core/contracts/libraries/Hooks.sol"; +import {BaseHook} from "../../BaseHook.sol"; +import {SafeCast} from "@uniswap/v4-core/contracts/libraries/SafeCast.sol"; +import {IHooks} from "@uniswap/v4-core/contracts/interfaces/IHooks.sol"; +import {CurrencyLibrary, Currency} from "@uniswap/v4-core/contracts/types/Currency.sol"; +import {TickMath} from "@uniswap/v4-core/contracts/libraries/TickMath.sol"; +import {BalanceDelta} from "@uniswap/v4-core/contracts/types/BalanceDelta.sol"; +import {IERC20Minimal} from "@uniswap/v4-core/contracts/interfaces/external/IERC20Minimal.sol"; +import {ILockCallback} from "@uniswap/v4-core/contracts/interfaces/callback/ILockCallback.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/contracts/types/PoolId.sol"; +import {PoolKey} from "@uniswap/v4-core/contracts/types/PoolKey.sol"; +import {FullMath} from "@uniswap/v4-core/contracts/libraries/FullMath.sol"; +import {UniswapV4ERC20} from "../../libraries/UniswapV4ERC20.sol"; +import {FixedPoint96} from "@uniswap/v4-core/contracts/libraries/FixedPoint96.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +import "../../libraries/LiquidityAmounts.sol"; + +contract FullRange is BaseHook, ILockCallback { + using CurrencyLibrary for Currency; + using PoolIdLibrary for PoolKey; + using SafeCast for uint256; + using SafeCast for uint128; + + /// @notice Thrown when trying to interact with a non-initialized pool + error PoolNotInitialized(); + error TickSpacingNotDefault(); + error LiquidityDoesntMeetMinimum(); + error SenderMustBeHook(); + error ExpiredPastDeadline(); + error TooMuchSlippage(); + + /// @dev Min tick for full range with tick spacing of 60 + int24 internal constant MIN_TICK = -887220; + /// @dev Max tick for full range with tick spacing of 60 + int24 internal constant MAX_TICK = -MIN_TICK; + + int256 internal constant MAX_INT = type(int256).max; + uint16 internal constant MINIMUM_LIQUIDITY = 1000; + + struct CallbackData { + address sender; + PoolKey key; + IPoolManager.ModifyPositionParams params; + } + + struct PoolInfo { + bool hasAccruedFees; + address liquidityToken; + } + + struct AddLiquidityParams { + address token0; + address token1; + uint24 fee; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address to; + uint256 deadline; + } + + struct RemoveLiquidityParams { + address token0; + address token1; + uint24 fee; + uint256 liquidity; + uint256 deadline; + } + + mapping(PoolId => PoolInfo) public poolInfo; + + constructor(IPoolManager _poolManager) BaseHook(_poolManager) {} + + modifier ensure(uint256 deadline) { + if (deadline < block.timestamp) revert ExpiredPastDeadline(); + _; + } + + function getHooksCalls() public pure override returns (Hooks.Calls memory) { + return Hooks.Calls({ + beforeInitialize: true, + afterInitialize: false, + beforeModifyPosition: true, + afterModifyPosition: false, + beforeSwap: true, + afterSwap: false, + beforeDonate: false, + afterDonate: false + }); + } + + function addLiquidity(AddLiquidityParams calldata params) + external + ensure(params.deadline) + returns (uint128 liquidity) + { + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(params.token0), + currency1: Currency.wrap(params.token1), + fee: params.fee, + tickSpacing: 60, + hooks: IHooks(address(this)) + }); + + PoolId poolId = key.toId(); + + (uint160 sqrtPriceX96,,,,,) = poolManager.getSlot0(poolId); + + if (sqrtPriceX96 == 0) revert PoolNotInitialized(); + + PoolInfo storage pool = poolInfo[poolId]; + + uint128 poolLiquidity = poolManager.getLiquidity(poolId); + + liquidity = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(MIN_TICK), + TickMath.getSqrtRatioAtTick(MAX_TICK), + params.amount0Desired, + params.amount1Desired + ); + + if (poolLiquidity == 0 && liquidity <= MINIMUM_LIQUIDITY) { + revert LiquidityDoesntMeetMinimum(); + } + BalanceDelta addedDelta = modifyPosition( + key, + IPoolManager.ModifyPositionParams({ + tickLower: MIN_TICK, + tickUpper: MAX_TICK, + liquidityDelta: liquidity.toInt256() + }) + ); + + if (poolLiquidity == 0) { + // permanently lock the first MINIMUM_LIQUIDITY tokens + liquidity -= MINIMUM_LIQUIDITY; + UniswapV4ERC20(pool.liquidityToken).mint(address(0), MINIMUM_LIQUIDITY); + } + + UniswapV4ERC20(pool.liquidityToken).mint(params.to, liquidity); + + if (uint128(addedDelta.amount0()) < params.amount0Min || uint128(addedDelta.amount1()) < params.amount1Min) { + revert TooMuchSlippage(); + } + } + + function removeLiquidity(RemoveLiquidityParams calldata params) + public + virtual + ensure(params.deadline) + returns (BalanceDelta delta) + { + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(params.token0), + currency1: Currency.wrap(params.token1), + fee: params.fee, + tickSpacing: 60, + hooks: IHooks(address(this)) + }); + + PoolId poolId = key.toId(); + + (uint160 sqrtPriceX96,,,,,) = poolManager.getSlot0(poolId); + + if (sqrtPriceX96 == 0) revert PoolNotInitialized(); + + UniswapV4ERC20 erc20 = UniswapV4ERC20(poolInfo[poolId].liquidityToken); + + delta = modifyPosition( + key, + IPoolManager.ModifyPositionParams({ + tickLower: MIN_TICK, + tickUpper: MAX_TICK, + liquidityDelta: -(params.liquidity.toInt256()) + }) + ); + + erc20.burn(msg.sender, params.liquidity); + } + + function beforeInitialize(address, PoolKey calldata key, uint160) external override returns (bytes4) { + if (key.tickSpacing != 60) revert TickSpacingNotDefault(); + + PoolId poolId = key.toId(); + + string memory tokenSymbol = string( + abi.encodePacked( + "UniV4", + "-", + IERC20Metadata(Currency.unwrap(key.currency0)).symbol(), + "-", + IERC20Metadata(Currency.unwrap(key.currency1)).symbol(), + "-", + Strings.toString(uint256(key.fee)) + ) + ); + address poolToken = address(new UniswapV4ERC20(tokenSymbol, tokenSymbol)); + + poolInfo[poolId] = PoolInfo({hasAccruedFees: false, liquidityToken: poolToken}); + + return FullRange.beforeInitialize.selector; + } + + function beforeModifyPosition(address sender, PoolKey calldata, IPoolManager.ModifyPositionParams calldata) + external + view + override + returns (bytes4) + { + if (sender != address(this)) revert SenderMustBeHook(); + + return FullRange.beforeModifyPosition.selector; + } + + function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata) + external + override + returns (bytes4) + { + PoolId poolId = key.toId(); + + if (!poolInfo[poolId].hasAccruedFees) { + PoolInfo storage pool = poolInfo[poolId]; + pool.hasAccruedFees = true; + } + + return IHooks.beforeSwap.selector; + } + + function modifyPosition(PoolKey memory key, IPoolManager.ModifyPositionParams memory params) + internal + returns (BalanceDelta delta) + { + delta = abi.decode(poolManager.lock(abi.encode(CallbackData(msg.sender, key, params))), (BalanceDelta)); + } + + function _settleDeltas(address sender, PoolKey memory key, BalanceDelta delta) internal { + _settleDelta(sender, key.currency0, uint128(delta.amount0())); + _settleDelta(sender, key.currency1, uint128(delta.amount1())); + } + + function _settleDelta(address sender, Currency currency, uint128 amount) internal { + if (currency.isNative()) { + poolManager.settle{value: amount}(currency); + } else { + if (sender == address(this)) { + currency.transfer(address(poolManager), amount); + } else { + IERC20Minimal(Currency.unwrap(currency)).transferFrom(sender, address(poolManager), amount); + } + poolManager.settle(currency); + } + } + + function _takeDeltas(address sender, PoolKey memory key, BalanceDelta delta) internal { + poolManager.take(key.currency0, sender, uint256(uint128(-delta.amount0()))); + poolManager.take(key.currency1, sender, uint256(uint128(-delta.amount1()))); + } + + function _removeLiquidity(PoolKey memory key, IPoolManager.ModifyPositionParams memory params) + internal + returns (BalanceDelta delta) + { + PoolId poolId = key.toId(); + PoolInfo storage pool = poolInfo[poolId]; + + if (pool.hasAccruedFees) { + _rebalance(key); + } + + uint256 liquidityToRemove = FullMath.mulDiv( + uint256(-params.liquidityDelta), + poolManager.getLiquidity(poolId), + UniswapV4ERC20(pool.liquidityToken).totalSupply() + ); + + params.liquidityDelta = -(liquidityToRemove.toInt256()); + delta = poolManager.modifyPosition(key, params); + pool.hasAccruedFees = false; + } + + function lockAcquired(bytes calldata rawData) + external + override(ILockCallback, BaseHook) + poolManagerOnly + returns (bytes memory) + { + CallbackData memory data = abi.decode(rawData, (CallbackData)); + BalanceDelta delta; + + if (data.params.liquidityDelta < 0) { + delta = _removeLiquidity(data.key, data.params); + _takeDeltas(data.sender, data.key, delta); + } else { + delta = poolManager.modifyPosition(data.key, data.params); + _settleDeltas(data.sender, data.key, delta); + } + return abi.encode(delta); + } + + function _rebalance(PoolKey memory key) public { + PoolId poolId = key.toId(); + BalanceDelta balanceDelta = poolManager.modifyPosition( + key, + IPoolManager.ModifyPositionParams({ + tickLower: MIN_TICK, + tickUpper: MAX_TICK, + liquidityDelta: -(poolManager.getLiquidity(poolId).toInt256()) + }) + ); + + uint160 newSqrtPriceX96 = ( + FixedPointMathLib.sqrt( + FullMath.mulDiv(uint128(-balanceDelta.amount1()), FixedPoint96.Q96, uint128(-balanceDelta.amount0())) + ) * FixedPointMathLib.sqrt(FixedPoint96.Q96) + ).toUint160(); + + (uint160 sqrtPriceX96,,,,,) = poolManager.getSlot0(poolId); + + poolManager.swap( + key, + IPoolManager.SwapParams({ + zeroForOne: newSqrtPriceX96 < sqrtPriceX96, + amountSpecified: MAX_INT, + sqrtPriceLimitX96: newSqrtPriceX96 + }) + ); + + uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts( + newSqrtPriceX96, + TickMath.getSqrtRatioAtTick(MIN_TICK), + TickMath.getSqrtRatioAtTick(MAX_TICK), + uint256(uint128(-balanceDelta.amount0())), + uint256(uint128(-balanceDelta.amount1())) + ); + + BalanceDelta balanceDeltaAfter = poolManager.modifyPosition( + key, + IPoolManager.ModifyPositionParams({ + tickLower: MIN_TICK, + tickUpper: MAX_TICK, + liquidityDelta: liquidity.toInt256() + }) + ); + + // Donate any "dust" from the sqrtRatio change as fees + uint128 donateAmount0 = uint128(-balanceDelta.amount0() - balanceDeltaAfter.amount0()); + uint128 donateAmount1 = uint128(-balanceDelta.amount1() - balanceDeltaAfter.amount1()); + + poolManager.donate(key, donateAmount0, donateAmount1); + } +} diff --git a/contracts/libraries/LiquidityAmounts.sol b/contracts/libraries/LiquidityAmounts.sol new file mode 100644 index 00000000..b2c8b54c --- /dev/null +++ b/contracts/libraries/LiquidityAmounts.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "@uniswap/v4-core/contracts/libraries/FullMath.sol"; +import "@uniswap/v4-core/contracts/libraries/FixedPoint96.sol"; + +/// @title Liquidity amount functions +/// @notice Provides functions for computing liquidity amounts from token amounts and prices +library LiquidityAmounts { + /// @notice Downcasts uint256 to uint128 + /// @param x The uint258 to be downcasted + /// @return y The passed value, downcasted to uint128 + function toUint128(uint256 x) private pure returns (uint128 y) { + require((y = uint128(x)) == x); + } + + /// @notice Computes the amount of liquidity received for a given amount of token0 and price range + /// @dev Calculates amount0 * (sqrt(upper) * sqrt(lower)) / (sqrt(upper) - sqrt(lower)) + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param amount0 The amount0 being sent in + /// @return liquidity The amount of returned liquidity + function getLiquidityForAmount0(uint160 sqrtRatioAX96, uint160 sqrtRatioBX96, uint256 amount0) + internal + pure + returns (uint128 liquidity) + { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + uint256 intermediate = FullMath.mulDiv(sqrtRatioAX96, sqrtRatioBX96, FixedPoint96.Q96); + return toUint128(FullMath.mulDiv(amount0, intermediate, sqrtRatioBX96 - sqrtRatioAX96)); + } + + /// @notice Computes the amount of liquidity received for a given amount of token1 and price range + /// @dev Calculates amount1 / (sqrt(upper) - sqrt(lower)). + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param amount1 The amount1 being sent in + /// @return liquidity The amount of returned liquidity + function getLiquidityForAmount1(uint160 sqrtRatioAX96, uint160 sqrtRatioBX96, uint256 amount1) + internal + pure + returns (uint128 liquidity) + { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + return toUint128(FullMath.mulDiv(amount1, FixedPoint96.Q96, sqrtRatioBX96 - sqrtRatioAX96)); + } + + /// @notice Computes the maximum amount of liquidity received for a given amount of token0, token1, the current + /// pool prices and the prices at the tick boundaries + /// @param sqrtRatioX96 A sqrt price representing the current pool prices + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param amount0 The amount of token0 being sent in + /// @param amount1 The amount of token1 being sent in + /// @return liquidity The maximum amount of liquidity received + function getLiquidityForAmounts( + uint160 sqrtRatioX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint256 amount0, + uint256 amount1 + ) internal pure returns (uint128 liquidity) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + if (sqrtRatioX96 <= sqrtRatioAX96) { + liquidity = getLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0); + } else if (sqrtRatioX96 < sqrtRatioBX96) { + uint128 liquidity0 = getLiquidityForAmount0(sqrtRatioX96, sqrtRatioBX96, amount0); + uint128 liquidity1 = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioX96, amount1); + + liquidity = liquidity0 < liquidity1 ? liquidity0 : liquidity1; + } else { + liquidity = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1); + } + } + + /// @notice Computes the amount of token0 for a given amount of liquidity and a price range + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param liquidity The liquidity being valued + /// @return amount0 The amount of token0 + function getAmount0ForLiquidity(uint160 sqrtRatioAX96, uint160 sqrtRatioBX96, uint128 liquidity) + internal + pure + returns (uint256 amount0) + { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + return FullMath.mulDiv( + uint256(liquidity) << FixedPoint96.RESOLUTION, sqrtRatioBX96 - sqrtRatioAX96, sqrtRatioBX96 + ) / sqrtRatioAX96; + } + + /// @notice Computes the amount of token1 for a given amount of liquidity and a price range + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param liquidity The liquidity being valued + /// @return amount1 The amount of token1 + function getAmount1ForLiquidity(uint160 sqrtRatioAX96, uint160 sqrtRatioBX96, uint128 liquidity) + internal + pure + returns (uint256 amount1) + { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + return FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96); + } + + /// @notice Computes the token0 and token1 value for a given amount of liquidity, the current + /// pool prices and the prices at the tick boundaries + /// @param sqrtRatioX96 A sqrt price representing the current pool prices + /// @param sqrtRatioAX96 A sqrt price representing the first tick boundary + /// @param sqrtRatioBX96 A sqrt price representing the second tick boundary + /// @param liquidity The liquidity being valued + /// @return amount0 The amount of token0 + /// @return amount1 The amount of token1 + function getAmountsForLiquidity( + uint160 sqrtRatioX96, + uint160 sqrtRatioAX96, + uint160 sqrtRatioBX96, + uint128 liquidity + ) internal pure returns (uint256 amount0, uint256 amount1) { + if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96); + + if (sqrtRatioX96 <= sqrtRatioAX96) { + amount0 = getAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity); + } else if (sqrtRatioX96 < sqrtRatioBX96) { + amount0 = getAmount0ForLiquidity(sqrtRatioX96, sqrtRatioBX96, liquidity); + amount1 = getAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioX96, liquidity); + } else { + amount1 = getAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity); + } + } +} diff --git a/contracts/libraries/UniswapV4ERC20.sol b/contracts/libraries/UniswapV4ERC20.sol new file mode 100644 index 00000000..fdd93ba4 --- /dev/null +++ b/contracts/libraries/UniswapV4ERC20.sol @@ -0,0 +1,16 @@ +pragma solidity ^0.8.19; + +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {Owned} from "solmate/auth/Owned.sol"; + +contract UniswapV4ERC20 is ERC20, Owned { + constructor(string memory name, string memory symbol) ERC20(name, symbol, 18) Owned(msg.sender) {} + + function mint(address account, uint256 amount) external onlyOwner { + _mint(account, amount); + } + + function burn(address account, uint256 amount) external onlyOwner { + _burn(account, amount); + } +} diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index d00acef4..5ae63068 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit d00acef4059807535af0bd0dd0ddf619747a044b +Subproject commit 5ae630684a0f57de400ef69499addab4c32ac8fb diff --git a/lib/solmate b/lib/solmate new file mode 160000 index 00000000..bfc9c258 --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit bfc9c25865a274a7827fea5abf6e4fb64fc64e6c diff --git a/remappings.txt b/remappings.txt index 7246666d..e05c5bd6 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,3 +1,4 @@ -@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ @uniswap/v4-core/=lib/v4-core/ +solmate/=lib/solmate/src/ forge-std/=lib/forge-std/src/ +@openzeppelin/=lib/openzeppelin-contracts/ diff --git a/test/FullRange.t.sol b/test/FullRange.t.sol new file mode 100644 index 00000000..29a4e12a --- /dev/null +++ b/test/FullRange.t.sol @@ -0,0 +1,851 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {Hooks} from "@uniswap/v4-core/contracts/libraries/Hooks.sol"; +import {FullRange} from "../contracts/hooks/examples/FullRange.sol"; +import {FullRangeImplementation} from "./shared/implementation/FullRangeImplementation.sol"; +import {PoolManager} from "@uniswap/v4-core/contracts/PoolManager.sol"; +import {IPoolManager} from "@uniswap/v4-core/contracts/interfaces/IPoolManager.sol"; +import {Deployers} from "@uniswap/v4-core/test/foundry-tests/utils/Deployers.sol"; +import {MockERC20} from "@uniswap/v4-core/test/foundry-tests/utils/MockERC20.sol"; +import {Currency} from "@uniswap/v4-core/contracts/types/Currency.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/contracts/types/PoolId.sol"; +import {PoolKey} from "@uniswap/v4-core/contracts/types/PoolKey.sol"; +import {PoolModifyPositionTest} from "@uniswap/v4-core/contracts/test/PoolModifyPositionTest.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/contracts/test/PoolSwapTest.sol"; +import {IHooks} from "@uniswap/v4-core/contracts/interfaces/IHooks.sol"; +import {UniswapV4ERC20} from "../contracts/libraries/UniswapV4ERC20.sol"; +import {FullMath} from "@uniswap/v4-core/contracts/libraries/FullMath.sol"; +import {SafeCast} from "@uniswap/v4-core/contracts/libraries/SafeCast.sol"; + +contract TestFullRange is Test, Deployers, GasSnapshot { + using PoolIdLibrary for PoolKey; + using SafeCast for uint256; + + event Initialize( + PoolId indexed poolId, + Currency indexed currency0, + Currency indexed currency1, + uint24 fee, + int24 tickSpacing, + IHooks hooks + ); + event ModifyPosition( + PoolId indexed poolId, address indexed sender, int24 tickLower, int24 tickUpper, int256 liquidityDelta + ); + event Swap( + PoolId indexed id, + address indexed sender, + int128 amount0, + int128 amount1, + uint160 sqrtPriceX96, + uint128 liquidity, + int24 tick, + uint24 fee + ); + + /// @dev Min tick for full range with tick spacing of 60 + int24 internal constant MIN_TICK = -887220; + /// @dev Max tick for full range with tick spacing of 60 + int24 internal constant MAX_TICK = -MIN_TICK; + + int24 constant TICK_SPACING = 60; + uint16 constant LOCKED_LIQUIDITY = 1000; + uint256 constant MAX_DEADLINE = 12329839823; + uint256 constant MAX_TICK_LIQUIDITY = 11505069308564788430434325881101412; + uint8 constant DUST = 30; + + MockERC20 token0; + MockERC20 token1; + MockERC20 token2; + + PoolManager manager; + FullRangeImplementation fullRange = FullRangeImplementation( + address(uint160(Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_MODIFY_POSITION_FLAG | Hooks.BEFORE_SWAP_FLAG)) + ); + + PoolKey key; + PoolId id; + + PoolKey key2; + PoolId id2; + + // For a pool that gets initialized with liquidity in setUp() + PoolKey keyWithLiq; + PoolId idWithLiq; + + PoolModifyPositionTest modifyPositionRouter; + PoolSwapTest swapRouter; + + function setUp() public { + token0 = new MockERC20("token0", "0", 18); + token1 = new MockERC20("token1", "1", 18); + token2 = new MockERC20("token2", "2", 18); + + token0.mint(address(this), 2 ** 128); + token1.mint(address(this), 2 ** 128); + token2.mint(address(this), 2 ** 128); + + manager = new PoolManager(500000); + + FullRangeImplementation impl = new FullRangeImplementation(manager, fullRange); + vm.etch(address(fullRange), address(impl).code); + + key = PoolKey(Currency.wrap(address(token0)), Currency.wrap(address(token1)), 3000, TICK_SPACING, fullRange); + id = key.toId(); + + key2 = PoolKey(Currency.wrap(address(token1)), Currency.wrap(address(token2)), 3000, TICK_SPACING, fullRange); + id2 = key.toId(); + + keyWithLiq = + PoolKey(Currency.wrap(address(token0)), Currency.wrap(address(token2)), 3000, TICK_SPACING, fullRange); + idWithLiq = keyWithLiq.toId(); + + modifyPositionRouter = new PoolModifyPositionTest(manager); + swapRouter = new PoolSwapTest(manager); + + token0.approve(address(fullRange), type(uint256).max); + token1.approve(address(fullRange), type(uint256).max); + token2.approve(address(fullRange), type(uint256).max); + token0.approve(address(swapRouter), type(uint256).max); + token1.approve(address(swapRouter), type(uint256).max); + token2.approve(address(swapRouter), type(uint256).max); + + manager.initialize(keyWithLiq, SQRT_RATIO_1_1); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token2), + 3000, + 100 ether, + 100 ether, + 99 ether, + 99 ether, + address(this), + MAX_DEADLINE + ) + ); + } + + function testFullRange_beforeInitialize_AllowsPoolCreation() public { + PoolKey memory testKey = key; + + vm.expectEmit(true, true, true, true); + emit Initialize(id, testKey.currency0, testKey.currency1, testKey.fee, testKey.tickSpacing, testKey.hooks); + + snapStart("FullRangeInitialize"); + manager.initialize(testKey, SQRT_RATIO_1_1); + snapEnd(); + + (, address liquidityToken) = fullRange.poolInfo(id); + + assertFalse(liquidityToken == address(0)); + } + + function testFullRange_beforeInitialize_RevertsIfWrongSpacing() public { + PoolKey memory wrongKey = + PoolKey(Currency.wrap(address(token0)), Currency.wrap(address(token1)), 0, TICK_SPACING + 1, fullRange); + + vm.expectRevert(FullRange.TickSpacingNotDefault.selector); + manager.initialize(wrongKey, SQRT_RATIO_1_1); + } + + function testFullRange_addLiquidity_InitialAddSucceeds() public { + manager.initialize(key, SQRT_RATIO_1_1); + + uint256 prevBalance0 = MockERC20(token0).balanceOf(address(this)); + uint256 prevBalance1 = MockERC20(token1).balanceOf(address(this)); + + address token0Addr = address(token0); + address token1Addr = address(token1); + + FullRange.AddLiquidityParams memory addLiquidityParams = FullRange.AddLiquidityParams( + token0Addr, token1Addr, 3000, 10 ether, 10 ether, 9 ether, 9 ether, address(this), MAX_DEADLINE + ); + + snapStart("FullRangeAddInitialLiquidity"); + fullRange.addLiquidity(addLiquidityParams); + snapEnd(); + + (bool hasAccruedFees, address liquidityToken) = fullRange.poolInfo(id); + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + assertEq(manager.getLiquidity(id), liquidityTokenBal + LOCKED_LIQUIDITY); + + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 - 10 ether); + assertEq(MockERC20(token1).balanceOf(address(this)), prevBalance1 - 10 ether); + + assertEq(liquidityTokenBal, 10 ether - LOCKED_LIQUIDITY); + assertEq(hasAccruedFees, false); + } + + function testFullRange_addLiquidity_InitialAddFuzz(uint256 amount) public { + manager.initialize(key, SQRT_RATIO_1_1); + if (amount < LOCKED_LIQUIDITY) { + vm.expectRevert(FullRange.LiquidityDoesntMeetMinimum.selector); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, amount, amount, amount, amount, address(this), MAX_DEADLINE + ) + ); + } else if (amount > MAX_TICK_LIQUIDITY) { + vm.expectRevert(); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, amount, amount, amount, amount, address(this), MAX_DEADLINE + ) + ); + } else { + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, amount, amount, 0, 0, address(this), MAX_DEADLINE + ) + ); + + (bool hasAccruedFees, address liquidityToken) = fullRange.poolInfo(id); + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + assertEq(manager.getLiquidity(id), liquidityTokenBal + LOCKED_LIQUIDITY); + assertEq(hasAccruedFees, false); + } + } + + function testFullRange_addLiquidity_SubsequentAdd() public { + uint256 prevBalance0 = MockERC20(token0).balanceOf(address(this)); + uint256 prevBalance2 = MockERC20(token2).balanceOf(address(this)); + + (, address liquidityToken) = fullRange.poolInfo(idWithLiq); + uint256 prevLiquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + address token0Addr = address(token0); + address token2Addr = address(token2); + + FullRange.AddLiquidityParams memory addLiquidityParams = FullRange.AddLiquidityParams( + token0Addr, token2Addr, 3000, 10 ether, 10 ether, 9 ether, 9 ether, address(this), MAX_DEADLINE + ); + + snapStart("FullRangeAddLiquidity"); + fullRange.addLiquidity(addLiquidityParams); + snapEnd(); + + (bool hasAccruedFees,) = fullRange.poolInfo(idWithLiq); + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + assertEq(manager.getLiquidity(idWithLiq), liquidityTokenBal + LOCKED_LIQUIDITY); + + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 - 10 ether); + assertEq(MockERC20(token2).balanceOf(address(this)), prevBalance2 - 10 ether); + + assertEq(liquidityTokenBal, prevLiquidityTokenBal + 10 ether); + assertEq(hasAccruedFees, false); + } + + function testFullRange_addLiquidity_FailsIfNoPool() public { + vm.expectRevert(FullRange.PoolNotInitialized.selector); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 0, 10 ether, 10 ether, 9 ether, 9 ether, address(this), MAX_DEADLINE + ) + ); + } + + function testFullRange_addLiquidity_SwapThenAddSucceeds() public { + PoolKey memory testKey = key; + manager.initialize(key, SQRT_RATIO_1_1); + + uint256 prevBalance0 = MockERC20(token0).balanceOf(address(this)); + uint256 prevBalance1 = MockERC20(token1).balanceOf(address(this)); + (, address liquidityToken) = fullRange.poolInfo(id); + + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 10 ether, + 10 ether, + 9 ether, + 9 ether, + address(this), + MAX_DEADLINE + ) + ); + + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + assertEq(manager.getLiquidity(id), liquidityTokenBal + LOCKED_LIQUIDITY); + assertEq(liquidityTokenBal, 10 ether - LOCKED_LIQUIDITY); + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 - 10 ether); + assertEq(MockERC20(token1).balanceOf(address(this)), prevBalance1 - 10 ether); + + vm.expectEmit(true, true, true, true); + emit Swap( + id, address(swapRouter), 1 ether, -906610893880149131, 72045250990510446115798809072, 10 ether, -1901, 3000 + ); + + IPoolManager.SwapParams memory params = + IPoolManager.SwapParams({zeroForOne: true, amountSpecified: 1 ether, sqrtPriceLimitX96: SQRT_RATIO_1_2}); + PoolSwapTest.TestSettings memory settings = + PoolSwapTest.TestSettings({withdrawTokens: true, settleUsingTransfer: true}); + + snapStart("FullRangeSwap"); + swapRouter.swap(testKey, params, settings); + snapEnd(); + + (bool hasAccruedFees,) = fullRange.poolInfo(id); + + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 - 10 ether - 1 ether); + assertEq(MockERC20(token1).balanceOf(address(this)), prevBalance1 - 9093389106119850869); + assertEq(hasAccruedFees, true); + + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, 5 ether, 5 ether, 4 ether, 4 ether, address(this), MAX_DEADLINE + ) + ); + + (hasAccruedFees,) = fullRange.poolInfo(id); + liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + assertEq(manager.getLiquidity(id), liquidityTokenBal + LOCKED_LIQUIDITY); + assertEq(liquidityTokenBal, 14546694553059925434 - LOCKED_LIQUIDITY); + assertEq(hasAccruedFees, true); + } + + function testFullRange_addLiquidity_FailsIfTooMuchSlippage() public { + manager.initialize(key, SQRT_RATIO_1_1); + + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 10 ether, + 10 ether, + 10 ether, + 10 ether, + address(this), + MAX_DEADLINE + ) + ); + + IPoolManager.SwapParams memory params = + IPoolManager.SwapParams({zeroForOne: true, amountSpecified: 1000 ether, sqrtPriceLimitX96: SQRT_RATIO_1_2}); + PoolSwapTest.TestSettings memory settings = + PoolSwapTest.TestSettings({withdrawTokens: true, settleUsingTransfer: true}); + + swapRouter.swap(key, params, settings); + + vm.expectRevert(FullRange.TooMuchSlippage.selector); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 10 ether, + 10 ether, + 10 ether, + 10 ether, + address(this), + MAX_DEADLINE + ) + ); + } + + function testFullRange_swap_TwoSwaps() public { + PoolKey memory testKey = key; + manager.initialize(testKey, SQRT_RATIO_1_1); + + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 10 ether, + 10 ether, + 9 ether, + 9 ether, + address(this), + MAX_DEADLINE + ) + ); + + IPoolManager.SwapParams memory params = + IPoolManager.SwapParams({zeroForOne: true, amountSpecified: 1 ether, sqrtPriceLimitX96: SQRT_RATIO_1_2}); + PoolSwapTest.TestSettings memory settings = + PoolSwapTest.TestSettings({withdrawTokens: true, settleUsingTransfer: true}); + + snapStart("FullRangeFirstSwap"); + swapRouter.swap(testKey, params, settings); + snapEnd(); + + (bool hasAccruedFees,) = fullRange.poolInfo(id); + assertEq(hasAccruedFees, true); + + snapStart("FullRangeSecondSwap"); + swapRouter.swap(testKey, params, settings); + snapEnd(); + + (hasAccruedFees,) = fullRange.poolInfo(id); + assertEq(hasAccruedFees, true); + } + + function testFullRange_swap_TwoPools() public { + manager.initialize(key, SQRT_RATIO_1_1); + manager.initialize(key2, SQRT_RATIO_1_1); + + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 10 ether, + 10 ether, + 9 ether, + 9 ether, + address(this), + MAX_DEADLINE + ) + ); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token1), + address(token2), + 3000, + 10 ether, + 10 ether, + 9 ether, + 9 ether, + address(this), + MAX_DEADLINE + ) + ); + + IPoolManager.SwapParams memory params = + IPoolManager.SwapParams({zeroForOne: true, amountSpecified: 10000000, sqrtPriceLimitX96: SQRT_RATIO_1_2}); + + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({withdrawTokens: true, settleUsingTransfer: true}); + + swapRouter.swap(key, params, testSettings); + swapRouter.swap(key2, params, testSettings); + + (bool hasAccruedFees,) = fullRange.poolInfo(id); + assertEq(hasAccruedFees, true); + + (hasAccruedFees,) = fullRange.poolInfo(id2); + assertEq(hasAccruedFees, true); + } + + function testFullRange_removeLiquidity_InitialRemoveSucceeds() public { + uint256 prevBalance0 = MockERC20(token0).balanceOf(address(this)); + uint256 prevBalance2 = MockERC20(token2).balanceOf(address(this)); + + address token0Addr = address(token0); + address token2Addr = address(token2); + + (, address liquidityToken) = fullRange.poolInfo(idWithLiq); + + UniswapV4ERC20(liquidityToken).approve(address(fullRange), type(uint256).max); + + FullRange.RemoveLiquidityParams memory removeLiquidityParams = + FullRange.RemoveLiquidityParams(token0Addr, token2Addr, 3000, 1 ether, MAX_DEADLINE); + + snapStart("FullRangeRemoveLiquidity"); + fullRange.removeLiquidity(removeLiquidityParams); + snapEnd(); + + (bool hasAccruedFees,) = fullRange.poolInfo(idWithLiq); + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + assertEq(manager.getLiquidity(idWithLiq), liquidityTokenBal + LOCKED_LIQUIDITY); + assertEq(UniswapV4ERC20(liquidityToken).balanceOf(address(this)), 99 ether - LOCKED_LIQUIDITY + 5); + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 + 1 ether - 1); + assertEq(MockERC20(token2).balanceOf(address(this)), prevBalance2 + 1 ether - 1); + assertEq(hasAccruedFees, false); + } + + function testFullRange_removeLiquidity_InitialRemoveFuzz(uint256 amount) public { + manager.initialize(key, SQRT_RATIO_1_1); + + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 1000 ether, + 1000 ether, + 999 ether, + 999 ether, + address(this), + MAX_DEADLINE + ) + ); + + (, address liquidityToken) = fullRange.poolInfo(id); + + UniswapV4ERC20(liquidityToken).approve(address(fullRange), type(uint256).max); + + if (amount > UniswapV4ERC20(liquidityToken).balanceOf(address(this))) { + vm.expectRevert(); + fullRange.removeLiquidity( + FullRange.RemoveLiquidityParams(address(token0), address(token1), 3000, amount, MAX_DEADLINE) + ); + } else { + uint256 prevLiquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + fullRange.removeLiquidity( + FullRange.RemoveLiquidityParams(address(token0), address(token1), 3000, amount, MAX_DEADLINE) + ); + + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + (bool hasAccruedFees,) = fullRange.poolInfo(id); + + assertEq(prevLiquidityTokenBal - liquidityTokenBal, amount); + assertEq(manager.getLiquidity(id), liquidityTokenBal + LOCKED_LIQUIDITY); + assertEq(hasAccruedFees, false); + } + } + + function testFullRange_removeLiquidity_FailsIfNoPool() public { + vm.expectRevert(FullRange.PoolNotInitialized.selector); + fullRange.removeLiquidity( + FullRange.RemoveLiquidityParams(address(token0), address(token1), 0, 10 ether, MAX_DEADLINE) + ); + } + + function testFullRange_removeLiquidity_FailsIfNoLiquidity() public { + manager.initialize(key, SQRT_RATIO_1_1); + + (, address liquidityToken) = fullRange.poolInfo(id); + UniswapV4ERC20(liquidityToken).approve(address(fullRange), type(uint256).max); + + vm.expectRevert(); // Insufficient balance error from ERC20 contract + fullRange.removeLiquidity( + FullRange.RemoveLiquidityParams(address(token0), address(token1), 3000, 10 ether, MAX_DEADLINE) + ); + } + + function testFullRange_removeLiquidity_SucceedsWithPartial() public { + manager.initialize(key, SQRT_RATIO_1_1); + + uint256 prevBalance0 = MockERC20(token0).balanceOf(address(this)); + uint256 prevBalance1 = MockERC20(token1).balanceOf(address(this)); + + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 10 ether, + 10 ether, + 9 ether, + 9 ether, + address(this), + MAX_DEADLINE + ) + ); + + (, address liquidityToken) = fullRange.poolInfo(id); + + assertEq(UniswapV4ERC20(liquidityToken).balanceOf(address(this)), 10 ether - LOCKED_LIQUIDITY); + + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 - 10 ether); + assertEq(MockERC20(token1).balanceOf(address(this)), prevBalance1 - 10 ether); + + UniswapV4ERC20(liquidityToken).approve(address(fullRange), type(uint256).max); + + fullRange.removeLiquidity( + FullRange.RemoveLiquidityParams(address(token0), address(token1), 3000, 5 ether, MAX_DEADLINE) + ); + + (bool hasAccruedFees,) = fullRange.poolInfo(id); + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + assertEq(manager.getLiquidity(id), liquidityTokenBal + LOCKED_LIQUIDITY); + assertEq(liquidityTokenBal, 5 ether - LOCKED_LIQUIDITY); + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 - 5 ether - 1); + assertEq(MockERC20(token1).balanceOf(address(this)), prevBalance1 - 5 ether - 1); + assertEq(hasAccruedFees, false); + } + + function testFullRange_removeLiquidity_DiffRatios() public { + manager.initialize(key, SQRT_RATIO_1_1); + + uint256 prevBalance0 = MockERC20(token0).balanceOf(address(this)); + uint256 prevBalance1 = MockERC20(token1).balanceOf(address(this)); + + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 10 ether, + 10 ether, + 9 ether, + 9 ether, + address(this), + MAX_DEADLINE + ) + ); + + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 - 10 ether); + assertEq(MockERC20(token1).balanceOf(address(this)), prevBalance1 - 10 ether); + + (, address liquidityToken) = fullRange.poolInfo(id); + + assertEq(UniswapV4ERC20(liquidityToken).balanceOf(address(this)), 10 ether - LOCKED_LIQUIDITY); + + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 5 ether, + 2.5 ether, + 2 ether, + 2 ether, + address(this), + MAX_DEADLINE + ) + ); + + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 - 12.5 ether); + assertEq(MockERC20(token1).balanceOf(address(this)), prevBalance1 - 12.5 ether); + + assertEq(UniswapV4ERC20(liquidityToken).balanceOf(address(this)), 12.5 ether - LOCKED_LIQUIDITY); + + UniswapV4ERC20(liquidityToken).approve(address(fullRange), type(uint256).max); + + fullRange.removeLiquidity( + FullRange.RemoveLiquidityParams(address(token0), address(token1), 3000, 5 ether, MAX_DEADLINE) + ); + + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + assertEq(manager.getLiquidity(id), liquidityTokenBal + LOCKED_LIQUIDITY); + assertEq(liquidityTokenBal, 7.5 ether - LOCKED_LIQUIDITY); + assertEq(MockERC20(token0).balanceOf(address(this)), prevBalance0 - 7.5 ether - 1); + assertEq(MockERC20(token1).balanceOf(address(this)), prevBalance1 - 7.5 ether - 1); + } + + function testFullRange_removeLiquidity_SwapAndRebalance() public { + (, address liquidityToken) = fullRange.poolInfo(idWithLiq); + + IPoolManager.SwapParams memory params = + IPoolManager.SwapParams({zeroForOne: true, amountSpecified: 1 ether, sqrtPriceLimitX96: SQRT_RATIO_1_2}); + + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({withdrawTokens: true, settleUsingTransfer: true}); + + swapRouter.swap(keyWithLiq, params, testSettings); + + UniswapV4ERC20(liquidityToken).approve(address(fullRange), type(uint256).max); + + address token0Addr = address(token0); + address token2Addr = address(token2); + + FullRange.RemoveLiquidityParams memory removeLiquidityParams = + FullRange.RemoveLiquidityParams(token0Addr, token2Addr, 3000, 5 ether, MAX_DEADLINE); + + snapStart("FullRangeRemoveLiquidityAndRebalance"); + fullRange.removeLiquidity(removeLiquidityParams); + snapEnd(); + + (bool hasAccruedFees,) = fullRange.poolInfo(idWithLiq); + assertEq(hasAccruedFees, false); + } + + function testFullRange_removeLiquidity_RemoveAllFuzz(uint256 amount) public { + manager.initialize(key, SQRT_RATIO_1_1); + (, address liquidityToken) = fullRange.poolInfo(id); + + if (amount <= LOCKED_LIQUIDITY) { + vm.expectRevert(FullRange.LiquidityDoesntMeetMinimum.selector); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, amount, amount, amount, amount, address(this), MAX_DEADLINE + ) + ); + } else if (amount >= MAX_TICK_LIQUIDITY) { + vm.expectRevert(); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, amount, amount, amount, amount, address(this), MAX_DEADLINE + ) + ); + } else { + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, amount, amount, 0, 0, address(this), MAX_DEADLINE + ) + ); + + // Test contract removes liquidity, succeeds + UniswapV4ERC20(liquidityToken).approve(address(fullRange), type(uint256).max); + + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + fullRange.removeLiquidity( + FullRange.RemoveLiquidityParams(address(token0), address(token1), 3000, liquidityTokenBal, MAX_DEADLINE) + ); + + assertEq(manager.getLiquidity(id), LOCKED_LIQUIDITY); + } + } + + function testFullRange_removeLiquidity_ThreeLPsRemovePrincipalAndFees() public { + // Mint tokens for dummy addresses + token0.mint(address(1), 2 ** 128); + token1.mint(address(1), 2 ** 128); + token0.mint(address(2), 2 ** 128); + token1.mint(address(2), 2 ** 128); + + // Approve the hook + vm.prank(address(1)); + token0.approve(address(fullRange), type(uint256).max); + vm.prank(address(1)); + token1.approve(address(fullRange), type(uint256).max); + + vm.prank(address(2)); + token0.approve(address(fullRange), type(uint256).max); + vm.prank(address(2)); + token1.approve(address(fullRange), type(uint256).max); + + manager.initialize(key, SQRT_RATIO_1_1); + (, address liquidityToken) = fullRange.poolInfo(id); + + // Test contract adds liquidity + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 100 ether, + 100 ether, + 99 ether, + 99 ether, + address(this), + MAX_DEADLINE + ) + ); + + // address(1) adds liquidity + vm.prank(address(1)); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 100 ether, + 100 ether, + 99 ether, + 99 ether, + address(this), + MAX_DEADLINE + ) + ); + + // address(2) adds liquidity + vm.prank(address(2)); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), + address(token1), + 3000, + 100 ether, + 100 ether, + 99 ether, + 99 ether, + address(this), + MAX_DEADLINE + ) + ); + + IPoolManager.SwapParams memory params = + IPoolManager.SwapParams({zeroForOne: true, amountSpecified: 100 ether, sqrtPriceLimitX96: SQRT_RATIO_1_4}); + + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({withdrawTokens: true, settleUsingTransfer: true}); + + swapRouter.swap(key, params, testSettings); + + (bool hasAccruedFees,) = fullRange.poolInfo(id); + assertEq(hasAccruedFees, true); + + // Test contract removes liquidity, succeeds + UniswapV4ERC20(liquidityToken).approve(address(fullRange), type(uint256).max); + fullRange.removeLiquidity( + FullRange.RemoveLiquidityParams( + address(token0), address(token1), 3000, 300 ether - LOCKED_LIQUIDITY, MAX_DEADLINE + ) + ); + (hasAccruedFees,) = fullRange.poolInfo(id); + + // PoolManager does not have any liquidity left over + assertTrue(manager.getLiquidity(id) >= LOCKED_LIQUIDITY); + assertTrue(manager.getLiquidity(id) < LOCKED_LIQUIDITY + DUST); + + assertEq(hasAccruedFees, false); + } + + function testFullRange_removeLiquidity_SwapRemoveAllFuzz(uint256 amount) public { + manager.initialize(key, SQRT_RATIO_1_1); + (, address liquidityToken) = fullRange.poolInfo(id); + + if (amount <= LOCKED_LIQUIDITY) { + vm.expectRevert(FullRange.LiquidityDoesntMeetMinimum.selector); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, amount, amount, amount, amount, address(this), MAX_DEADLINE + ) + ); + } else if (amount >= MAX_TICK_LIQUIDITY) { + vm.expectRevert(); + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, amount, amount, amount, amount, address(this), MAX_DEADLINE + ) + ); + } else { + fullRange.addLiquidity( + FullRange.AddLiquidityParams( + address(token0), address(token1), 3000, amount, amount, 0, 0, address(this), MAX_DEADLINE + ) + ); + + IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ + zeroForOne: true, + amountSpecified: (FullMath.mulDiv(amount, 1, 4)).toInt256(), + sqrtPriceLimitX96: SQRT_RATIO_1_4 + }); + + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({withdrawTokens: true, settleUsingTransfer: true}); + + swapRouter.swap(key, params, testSettings); + + // Test contract removes liquidity, succeeds + UniswapV4ERC20(liquidityToken).approve(address(fullRange), type(uint256).max); + + uint256 liquidityTokenBal = UniswapV4ERC20(liquidityToken).balanceOf(address(this)); + + fullRange.removeLiquidity( + FullRange.RemoveLiquidityParams(address(token0), address(token1), 3000, liquidityTokenBal, MAX_DEADLINE) + ); + + assertTrue(manager.getLiquidity(id) <= LOCKED_LIQUIDITY + DUST); + } + } + + function testFullRange_BeforeModifyPositionFailsWithWrongMsgSender() public { + manager.initialize(key, SQRT_RATIO_1_1); + + vm.expectRevert(FullRange.SenderMustBeHook.selector); + + modifyPositionRouter.modifyPosition( + key, IPoolManager.ModifyPositionParams({tickLower: MIN_TICK, tickUpper: MAX_TICK, liquidityDelta: 100}) + ); + } +} diff --git a/test/shared/implementation/FullRangeImplementation.sol b/test/shared/implementation/FullRangeImplementation.sol new file mode 100644 index 00000000..fcd8ae3f --- /dev/null +++ b/test/shared/implementation/FullRangeImplementation.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import {BaseHook} from "../../../contracts/BaseHook.sol"; +import {FullRange} from "../../../contracts/hooks/examples/FullRange.sol"; +import {IPoolManager} from "@uniswap/v4-core/contracts/interfaces/IPoolManager.sol"; +import {Hooks} from "@uniswap/v4-core/contracts/libraries/Hooks.sol"; + +contract FullRangeImplementation is FullRange { + constructor(IPoolManager _poolManager, FullRange addressToEtch) FullRange(_poolManager) { + Hooks.validateHookAddress(addressToEtch, getHooksCalls()); + } + + // make this a no-op in testing + function validateHookAddress(BaseHook _this) internal pure override {} +}