diff --git a/remappings.txt b/remappings.txt index 00b9606..c870d3e 100644 --- a/remappings.txt +++ b/remappings.txt @@ -3,3 +3,5 @@ ds-test/=lib/forge-std/lib/ds-test/src/ pancake-v4-core/=lib/pancake-v4-core/ @openzeppelin/=lib/openzeppelin-contracts/ solmate/=lib/solmate/src/ +@uniswap/v2-core/=lib/v2-core/ +@uniswap/v3-periphery/=lib/v3-periphery/ diff --git a/src/base/BaseMigrator.sol b/src/base/BaseMigrator.sol new file mode 100644 index 0000000..32c7f2f --- /dev/null +++ b/src/base/BaseMigrator.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2024 PancakeSwap +pragma solidity ^0.8.19; + +import {IUniswapV2Pair} from "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol"; +import {INonfungiblePositionManager} from "@uniswap/v3-periphery/contracts/interfaces/INonfungiblePositionManager.sol"; +import {PeripheryImmutableState} from "./PeripheryImmutableState.sol"; + +contract BaseMigrator is PeripheryImmutableState, Multicall, SelfPermit { + constructor(address _WETH9) PeripheryImmutableState(_WETH9) {} + + function withdrawLiquidityFromV2(address pair, uint256 amount) + internal + returns (uint256 amount0Received, uint256 amount1Received) + { + // burn v2 liquidity to this address + IUniswapV2Pair(pair).transferFrom(msg.sender, pair, amount); + return IUniswapV2Pair(pair).burn(address(this)); + } + + function withdrawLiquidityFromV3( + address nfp, + INonfungiblePositionManager.DecreaseLiquidityParams decreaseLiquidityParams, + bool collectFee + ) internal returns (uint256 amount0Received, uint256 amount1Received) { + // TODO: consider batching decreaseLiquidity and collect + + /// @notice decrease liquidity from v3#nfp, make sure migrator has been approved + (amount0Received, amount1Received) = INonfungiblePositionManager(nfp).decreaseLiquidity(decreaseLiquidityParams); + + INonfungiblePositionManager.CollectParams collectParams = INonfungiblePositionManager.CollectParams({ + tokenId: decreaseLiquidityParams.tokenId, + recipient: address(this), + amount0Max: collectFee ? type(uint128).max : amount0Received, + amount1Max: collectFee ? type(uint128).max : amount1Received + }); + + return INonfungiblePositionManager(nfp).collect(collectParams); + } + + function refund(address token, address to, uint256 amount) internal { + if (token == WETH9) { + IWETH9(WETH9).withdraw(amount); + TransferHelper.safeTransferETH(to, amount); + } else { + TransferHelper.safeTransfer(token, to, amount); + } + } + + receive() external payable { + require(msg.sender == WETH9, "Not WETH9"); + } +} diff --git a/src/pool-cl/CLMigrator.sol b/src/pool-cl/CLMigrator.sol new file mode 100644 index 0000000..9f66c4d --- /dev/null +++ b/src/pool-cl/CLMigrator.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2024 PancakeSwap +pragma solidity ^0.8.19; + +import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; +import {BaseMigrator} from "../base/BaseMigrator.sol"; +import {ICLMigrator} from "./interfaces/ICLMigrator.sol"; +import {INonfungiblePositionManager} from "./interfaces/INonfungiblePositionManager.sol"; + +contract CLMigrator is ICLMigrator, BaseMigrator { + using LowGasSafeMath for uint256; + + INonfungiblePositionManager public immutable nonfungiblePositionManager; + + constructor(address _WETH9, address _nonfungiblePositionManager) BaseMigrator(_WETH9) { + nonfungiblePositionManager = _nonfungiblePositionManager; + } + + function migrateFromV2(MigrateFromV2Params calldata params) external override { + // 1. burn v2 liquidity to this address + (uint256 amount0Received, uint256 amount1Received) = + withdrawLiquidityFromV2(params.pair, params.liquidityToMigrate); + + if (params.amount0In != 0) { + SafeTransferLib.safeTransferFrom(params.poolKey.currency0, msg.sender, address(this), params.amount0In); + } + if (params.amount1In != 0) { + SafeTransferLib.safeTransferFrom(params.poolKey.currency1, msg.sender, address(this), params.amount1In); + } + + // 2. mint v4 position token, token sent to recipient + // TO BE CONFIRMED: + // Consider the case from a WETH pool to a ETH pool (v2 not support ETH pool but v4 does): + // in that case we might need to unwrap WETH to ETH and send to the nfp contract + // but how many token should be sent to the nfp contract ? + // i don't see nfp have refund logic, what if the token consumed is less than the token sent ? + SafeTransferLib.safeApprove(params.poolKey.currency0, address(nonfungiblePositionManager), amount0Desired); + SafeTransferLib.safeApprove(params.poolKey.currency1, address(nonfungiblePositionManager), amount1Desired); + + // TODO: init the pool if necessary + + (uint256 tokenId, uint128 liquidity, uint256 amount0Consumed, uint256 amount1Consumed) = + nonfungiblePositionManager.mint( + MintParams({ + PoolKey: params.poolKey, + tickLower: params.tickLower, + tickUpper: params.tickUpper, + salt: params.salt, + amount0Desired: params.amount0Desired, + amount1Desired: params.amount1Desired, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + recipient: params.recipient, + deadline: params.deadline + }) + ); + + // TODO: any other check here? + + // 3. clear allowance and refund if necessary + if (params.amount0Desired > amount0Consumed) { + SafeTransferLib.safeApprove(params.poolKey.currency0, address(nonfungiblePositionManager), 0); + } + if (params.amount1Desired > amount1Consumed) { + SafeTransferLib.safeApprove(params.poolKey.currency1, address(nonfungiblePositionManager), 0); + } + + if (amount0Received > amount0Consumed) { + refund(params.poolKey.currency0, params.recipient, amount0Received - amount0Consumed); + } + + if (amount1Received > amount1Consumed) { + refund(params.poolKey.currency1, params.recipient, amount1Received - amount1Consumed); + } + + // TODO: confirm whether we need any events here + } + + function migrateFromV3(MigrateFromV3Params calldata params) external override { + // 1. burn v3 liquidity to this address + (uint256 amount0Received, uint256 amount1Received) = withdrawLiquidityFromV3( + params.nfp, + INonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: params.tokenId, + liquidity: params.liquidity, + amount0Min: params.amount0MinForV3, + amount1Min: params.amount1MinForV3 + }), + params.collectFee + ); + + if (params.amount0In != 0) { + SafeTransferLib.safeTransferFrom(params.poolKey.currency0, msg.sender, address(this), params.amount0In); + } + if (params.amount1In != 0) { + SafeTransferLib.safeTransferFrom(params.poolKey.currency1, msg.sender, address(this), params.amount1In); + } + + // 2. mint v4 position token, token sent to recipient + // TO BE CONFIRMED: + // Consider the case from a WETH pool to a ETH pool (v3 not support ETH pool but v4 does): + // in that case we might need to unwrap WETH to ETH and send to the nfp contract + // but how many token should be sent to the nfp contract ? + // i don't see nfp have refund logic, what if the token consumed is less than the token sent ? + SafeTransferLib.safeApprove(params.poolKey.currency0, address(nonfungiblePositionManager), amount0Desired); + SafeTransferLib.safeApprove(params.poolKey.currency1, address(nonfungiblePositionManager), amount1Desired); + + // TODO: init the pool if necessary + + (uint256 tokenId, uint128 liquidity, uint256 amount0Consumed, uint256 amount1Consumed) = + nonfungiblePositionManager.mint( + MintParams({ + PoolKey: params.poolKey, + tickLower: params.tickLower, + tickUpper: params.tickUpper, + salt: params.salt, + amount0Desired: params.amount0Desired, + amount1Desired: params.amount1Desired, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + recipient: params.recipient, + deadline: params.deadline + }) + ); + + // TODO: any other check here? + + // 3. clear allowance and refund if necessary + if (params.amount0Desired > amount0Consumed) { + SafeTransferLib.safeApprove(params.poolKey.currency0, address(nonfungiblePositionManager), 0); + } + if (params.amount1Desired > amount1Consumed) { + SafeTransferLib.safeApprove(params.poolKey.currency1, address(nonfungiblePositionManager), 0); + } + + if (amount0Received > amount0Consumed) { + refund(params.poolKey.currency0, params.recipient, amount0Received - amount0Consumed); + } + + if (amount1Received > amount1Consumed) { + refund(params.poolKey.currency1, params.recipient, amount1Received - amount1Consumed); + } + + // TODO: confirm whether we need any events here + } +} diff --git a/src/pool-cl/interfaces/ICLMigrator.sol b/src/pool-cl/interfaces/ICLMigrator.sol new file mode 100644 index 0000000..746c5bb --- /dev/null +++ b/src/pool-cl/interfaces/ICLMigrator.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2024 PancakeSwap +pragma solidity ^0.8.19; + +import {PoolKey} from "pancake-v4-core/src/types/PoolKey.sol"; + +interface ICLMigrator { + struct MigrateFromV2Params { + // source v2 pool params + address pair; // the PancakeSwap v2-compatible pair + uint256 liquidityToMigrate; // the amount of liquidity to migrate + // target v4 pool params + PoolKey poolKey; + int24 tickLower; + int24 tickUpper; + bytes32 salt; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + // amt0, amt1 that will be added to the pool + // usually when ppl specify price range manually + uint256 amount0In; + uint256 amount1In; + bool refundAsETH; + } + + struct MigrateFromV3Params { + // source v3 pool params + address nfp; // the PancakeSwap v3-compatible NFP + uint256 tokenId; + uint128 liquidity; + uint256 amount0MinForV3; + uint256 amount1MinForV3; + bool collectFee; + // target v4 pool params + PoolKey poolKey; // the target v4 pool + int24 tickLower; + int24 tickUpper; + bytes32 salt; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + // amt0, amt1 that will be added to the pool + // usually when ppl specify price range manually + uint256 amount0In; + uint256 amount1In; + bool refundAsETH; + } + + function migrateFromV2(MigrateFromV2Params calldata params) external; + + function migrateFromV3(MigrateFromV3Params calldata params) external; +}