diff --git a/.forge-snapshots/BinMigratorFromPancakeswapV2Test#testMigrateFromV2IncludingInit.snap b/.forge-snapshots/BinMigratorFromPancakeswapV2Test#testMigrateFromV2IncludingInit.snap new file mode 100644 index 0000000..c5ce088 --- /dev/null +++ b/.forge-snapshots/BinMigratorFromPancakeswapV2Test#testMigrateFromV2IncludingInit.snap @@ -0,0 +1 @@ +1017615 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutInit.snap b/.forge-snapshots/BinMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutInit.snap new file mode 100644 index 0000000..bfa5bac --- /dev/null +++ b/.forge-snapshots/BinMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutInit.snap @@ -0,0 +1 @@ +977598 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutNativeToken.snap b/.forge-snapshots/BinMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutNativeToken.snap new file mode 100644 index 0000000..e40db38 --- /dev/null +++ b/.forge-snapshots/BinMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutNativeToken.snap @@ -0,0 +1 @@ +1022017 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromPancakeswapV3Test#testMigrateFromV3IncludingInit.snap b/.forge-snapshots/BinMigratorFromPancakeswapV3Test#testMigrateFromV3IncludingInit.snap new file mode 100644 index 0000000..5a7fd6b --- /dev/null +++ b/.forge-snapshots/BinMigratorFromPancakeswapV3Test#testMigrateFromV3IncludingInit.snap @@ -0,0 +1 @@ +1096580 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutInit.snap b/.forge-snapshots/BinMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutInit.snap new file mode 100644 index 0000000..b4db3f3 --- /dev/null +++ b/.forge-snapshots/BinMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutInit.snap @@ -0,0 +1 @@ +1056639 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutNativeToken.snap b/.forge-snapshots/BinMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutNativeToken.snap new file mode 100644 index 0000000..11bbe69 --- /dev/null +++ b/.forge-snapshots/BinMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutNativeToken.snap @@ -0,0 +1 @@ +1094456 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromUniswapV2Test#testMigrateFromV2IncludingInit.snap b/.forge-snapshots/BinMigratorFromUniswapV2Test#testMigrateFromV2IncludingInit.snap new file mode 100644 index 0000000..432348a --- /dev/null +++ b/.forge-snapshots/BinMigratorFromUniswapV2Test#testMigrateFromV2IncludingInit.snap @@ -0,0 +1 @@ +1017627 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromUniswapV2Test#testMigrateFromV2WithoutInit.snap b/.forge-snapshots/BinMigratorFromUniswapV2Test#testMigrateFromV2WithoutInit.snap new file mode 100644 index 0000000..b5e449b --- /dev/null +++ b/.forge-snapshots/BinMigratorFromUniswapV2Test#testMigrateFromV2WithoutInit.snap @@ -0,0 +1 @@ +977610 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromUniswapV2Test#testMigrateFromV2WithoutNativeToken.snap b/.forge-snapshots/BinMigratorFromUniswapV2Test#testMigrateFromV2WithoutNativeToken.snap new file mode 100644 index 0000000..d35a8e0 --- /dev/null +++ b/.forge-snapshots/BinMigratorFromUniswapV2Test#testMigrateFromV2WithoutNativeToken.snap @@ -0,0 +1 @@ +1022014 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromUniswapV3Test#testMigrateFromV3IncludingInit.snap b/.forge-snapshots/BinMigratorFromUniswapV3Test#testMigrateFromV3IncludingInit.snap new file mode 100644 index 0000000..5284644 --- /dev/null +++ b/.forge-snapshots/BinMigratorFromUniswapV3Test#testMigrateFromV3IncludingInit.snap @@ -0,0 +1 @@ +1094562 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromUniswapV3Test#testMigrateFromV3WithoutInit.snap b/.forge-snapshots/BinMigratorFromUniswapV3Test#testMigrateFromV3WithoutInit.snap new file mode 100644 index 0000000..0798bc2 --- /dev/null +++ b/.forge-snapshots/BinMigratorFromUniswapV3Test#testMigrateFromV3WithoutInit.snap @@ -0,0 +1 @@ +1054621 \ No newline at end of file diff --git a/.forge-snapshots/BinMigratorFromUniswapV3Test#testMigrateFromV3WithoutNativeToken.snap b/.forge-snapshots/BinMigratorFromUniswapV3Test#testMigrateFromV3WithoutNativeToken.snap new file mode 100644 index 0000000..2802d5f --- /dev/null +++ b/.forge-snapshots/BinMigratorFromUniswapV3Test#testMigrateFromV3WithoutNativeToken.snap @@ -0,0 +1 @@ +1092434 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromPancakeswapV2Test#testMigrateFromV2IncludingInit.snap b/.forge-snapshots/CLMigratorFromPancakeswapV2Test#testMigrateFromV2IncludingInit.snap new file mode 100644 index 0000000..2fd85a1 --- /dev/null +++ b/.forge-snapshots/CLMigratorFromPancakeswapV2Test#testMigrateFromV2IncludingInit.snap @@ -0,0 +1 @@ +736974 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutInit.snap b/.forge-snapshots/CLMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutInit.snap new file mode 100644 index 0000000..1c78123 --- /dev/null +++ b/.forge-snapshots/CLMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutInit.snap @@ -0,0 +1 @@ +693861 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutNativeToken.snap b/.forge-snapshots/CLMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutNativeToken.snap new file mode 100644 index 0000000..577725f --- /dev/null +++ b/.forge-snapshots/CLMigratorFromPancakeswapV2Test#testMigrateFromV2WithoutNativeToken.snap @@ -0,0 +1 @@ +738290 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromPancakeswapV3Test#testMigrateFromV3IncludingInit.snap b/.forge-snapshots/CLMigratorFromPancakeswapV3Test#testMigrateFromV3IncludingInit.snap new file mode 100644 index 0000000..ca570ab --- /dev/null +++ b/.forge-snapshots/CLMigratorFromPancakeswapV3Test#testMigrateFromV3IncludingInit.snap @@ -0,0 +1 @@ +793398 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutInit.snap b/.forge-snapshots/CLMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutInit.snap new file mode 100644 index 0000000..01facce --- /dev/null +++ b/.forge-snapshots/CLMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutInit.snap @@ -0,0 +1 @@ +752832 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutNativeToken.snap b/.forge-snapshots/CLMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutNativeToken.snap new file mode 100644 index 0000000..ffc5693 --- /dev/null +++ b/.forge-snapshots/CLMigratorFromPancakeswapV3Test#testMigrateFromV3WithoutNativeToken.snap @@ -0,0 +1 @@ +794750 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromUniswapV2Test#testMigrateFromV2IncludingInit.snap b/.forge-snapshots/CLMigratorFromUniswapV2Test#testMigrateFromV2IncludingInit.snap new file mode 100644 index 0000000..26cffde --- /dev/null +++ b/.forge-snapshots/CLMigratorFromUniswapV2Test#testMigrateFromV2IncludingInit.snap @@ -0,0 +1 @@ +736986 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromUniswapV2Test#testMigrateFromV2WithoutInit.snap b/.forge-snapshots/CLMigratorFromUniswapV2Test#testMigrateFromV2WithoutInit.snap new file mode 100644 index 0000000..f8e8bf1 --- /dev/null +++ b/.forge-snapshots/CLMigratorFromUniswapV2Test#testMigrateFromV2WithoutInit.snap @@ -0,0 +1 @@ +693873 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromUniswapV2Test#testMigrateFromV2WithoutNativeToken.snap b/.forge-snapshots/CLMigratorFromUniswapV2Test#testMigrateFromV2WithoutNativeToken.snap new file mode 100644 index 0000000..7ab44b7 --- /dev/null +++ b/.forge-snapshots/CLMigratorFromUniswapV2Test#testMigrateFromV2WithoutNativeToken.snap @@ -0,0 +1 @@ +738287 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromUniswapV3Test#testMigrateFromV3IncludingInit.snap b/.forge-snapshots/CLMigratorFromUniswapV3Test#testMigrateFromV3IncludingInit.snap new file mode 100644 index 0000000..473137d --- /dev/null +++ b/.forge-snapshots/CLMigratorFromUniswapV3Test#testMigrateFromV3IncludingInit.snap @@ -0,0 +1 @@ +791380 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromUniswapV3Test#testMigrateFromV3WithoutInit.snap b/.forge-snapshots/CLMigratorFromUniswapV3Test#testMigrateFromV3WithoutInit.snap new file mode 100644 index 0000000..79eb1ec --- /dev/null +++ b/.forge-snapshots/CLMigratorFromUniswapV3Test#testMigrateFromV3WithoutInit.snap @@ -0,0 +1 @@ +750814 \ No newline at end of file diff --git a/.forge-snapshots/CLMigratorFromUniswapV3Test#testMigrateFromV3WithoutNativeToken.snap b/.forge-snapshots/CLMigratorFromUniswapV3Test#testMigrateFromV3WithoutNativeToken.snap new file mode 100644 index 0000000..0a4c561 --- /dev/null +++ b/.forge-snapshots/CLMigratorFromUniswapV3Test#testMigrateFromV3WithoutNativeToken.snap @@ -0,0 +1 @@ +792728 \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 450ca53..16daa11 100644 --- a/foundry.toml +++ b/foundry.toml @@ -9,6 +9,7 @@ fs_permissions = [ { access = "read-write", path = ".forge-snapshots/" }, { access = "read", path = "./foundry-out" }, { access = "read", path = "./script/config" }, + { access = "read", path = "./test/bin/" }, ] evm_version = 'cancun' diff --git a/src/base/BaseMigrator.sol b/src/base/BaseMigrator.sol new file mode 100644 index 0000000..e264bce --- /dev/null +++ b/src/base/BaseMigrator.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2024 PancakeSwap +pragma solidity ^0.8.19; + +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {SafeTransferLib, ERC20} from "solmate/utils/SafeTransferLib.sol"; +import {IPancakePair} from "../interfaces/external/IPancakePair.sol"; +import {IV3NonfungiblePositionManager} from "../interfaces/external/IV3NonfungiblePositionManager.sol"; +import {IWETH9} from "../interfaces/external/IWETH9.sol"; +import {PeripheryImmutableState} from "./PeripheryImmutableState.sol"; +import {Multicall} from "./Multicall.sol"; +import {SelfPermit} from "./SelfPermit.sol"; +import {Currency, CurrencyLibrary} from "pancake-v4-core/src/types/Currency.sol"; +import {SelfPermitERC721} from "./SelfPermitERC721.sol"; +import {IBaseMigrator} from "../interfaces/IBaseMigrator.sol"; + +contract BaseMigrator is IBaseMigrator, PeripheryImmutableState, Multicall, SelfPermit, SelfPermitERC721 { + constructor(address _WETH9) PeripheryImmutableState(_WETH9) {} + + /// @notice refund native ETH to caller + /// This is useful when the caller sends more ETH then he specifies in arguments + function refundETH() external payable override { + if (address(this).balance > 0) CurrencyLibrary.NATIVE.transfer(msg.sender, address(this).balance); + } + + /// @notice compare if tokens from v2 pair are the same as token0/token1. Revert with + /// `TOKEN_NOT_MATCH` if tokens does not match + /// @param v2Pair the address of v2 pair + /// @param token0 token0 of v4 poolKey + /// @param token1 token1 of v4 poolKey + /// @return shouldReversePair if the order of tokens from v2 pair is different from v4 pair (only when WETH is involved) + function checkTokensOrderAndMatchFromV2(address v2Pair, Currency token0, Currency token1) + internal + view + returns (bool shouldReversePair) + { + address token0V2 = IPancakePair(v2Pair).token0(); + address token1V2 = IPancakePair(v2Pair).token1(); + return _checkIfTokenPairMatchAndOrder(token0V2, token1V2, token0, token1); + } + + /// @notice compare if tokens from v3 pool are the same as token0/token1. Revert with + /// `TOKEN_NOT_MATCH` if tokens does not match + /// @param nfp the address of v3#nfp + /// @param tokenId the tokenId of v3 pool + /// @param token0 token0 of v4 poolKey + /// @param token1 token1 of v4 poolKey + /// @return shouldReversePair if the order of tokens from v3 pool is different from v4 pair (only when WETH is involved) + function checkTokensOrderAndMatchFromV3(address nfp, uint256 tokenId, Currency token0, Currency token1) + internal + view + returns (bool shouldReversePair) + { + (,, address token0V3, address token1V3,,,,,,,,) = IV3NonfungiblePositionManager(nfp).positions(tokenId); + return _checkIfTokenPairMatchAndOrder(token0V3, token1V3, token0, token1); + } + + /// @notice withdraw liquidity from v2 pool (fee will always be included) + /// It may revert if amount0/amount1 received is less than expected + /// @param v2PoolParams the parameters to withdraw liquidity from v2 pool + /// @param shouldReversePair if the order of tokens from v2 pair is different from v4 pair (only when WETH is involved) + /// @return amount0Received the actual amount of token0 received (in order of v4 pool) + /// @return amount1Received the actual amount of token1 received (in order of v4 pool) + function withdrawLiquidityFromV2(V2PoolParams calldata v2PoolParams, bool shouldReversePair) + internal + returns (uint256 amount0Received, uint256 amount1Received) + { + // burn v2 liquidity to this address + IPancakePair(v2PoolParams.pair).transferFrom(msg.sender, v2PoolParams.pair, v2PoolParams.migrateAmount); + (amount0Received, amount1Received) = IPancakePair(v2PoolParams.pair).burn(address(this)); + + // same price slippage check as v3 + if (amount0Received < v2PoolParams.amount0Min || amount1Received < v2PoolParams.amount1Min) { + revert INSUFFICIENT_AMOUNTS_RECEIVED(); + } + + /// @notice the order may mismatch with v4 pool when WETH is invovled + /// the following check makes sure that the output always match the order of v4 pool + if (shouldReversePair) { + (amount0Received, amount1Received) = (amount1Received, amount0Received); + } + } + + /// @notice withdraw liquidity from v3 pool and collect fee if specified in `v3PoolParams` + /// It may revert if the caller is not the owner of the token or amount0/amount1 received is less than expected + /// @param v3PoolParams the parameters to withdraw liquidity from v3 pool + /// @param shouldReversePair if the order of tokens from v3 pool is different from v4 pair (only when WETH is involved) + /// @return amount0Received the actual amount of token0 received (in order of v4 pool) + /// @return amount1Received the actual amount of token1 received (in order of v4 pool) + function withdrawLiquidityFromV3(V3PoolParams calldata v3PoolParams, bool shouldReversePair) + internal + returns (uint256 amount0Received, uint256 amount1Received) + { + IV3NonfungiblePositionManager nfp = IV3NonfungiblePositionManager(v3PoolParams.nfp); + uint256 tokenId = v3PoolParams.tokenId; + ///@dev make sure the caller is the owner of the token + /// otherwise once the token is approved to migrator, anyone can steal money through this function + if (msg.sender != nfp.ownerOf(tokenId)) { + revert NOT_TOKEN_OWNER(); + } + + /// @notice decrease liquidity from v3#nfp, make sure migrator has been approved + IV3NonfungiblePositionManager.DecreaseLiquidityParams memory decreaseLiquidityParams = + IV3NonfungiblePositionManager.DecreaseLiquidityParams({ + tokenId: tokenId, + liquidity: v3PoolParams.liquidity, + amount0Min: v3PoolParams.amount0Min, + amount1Min: v3PoolParams.amount1Min, + deadline: v3PoolParams.deadline + }); + (amount0Received, amount1Received) = nfp.decreaseLiquidity(decreaseLiquidityParams); + + /// @notice collect tokens from v3#nfp (including fee if necessary) + IV3NonfungiblePositionManager.CollectParams memory collectParams = IV3NonfungiblePositionManager.CollectParams({ + tokenId: tokenId, + recipient: address(this), + amount0Max: v3PoolParams.collectFee ? type(uint128).max : SafeCast.toUint128(amount0Received), + amount1Max: v3PoolParams.collectFee ? type(uint128).max : SafeCast.toUint128(amount1Received) + }); + (amount0Received, amount1Received) = nfp.collect(collectParams); + + /// @notice the order may mismatch with v4 pool when WETH is invovled + /// the following check makes sure that the output always match the order of v4 pool + if (shouldReversePair) { + (amount0Received, amount1Received) = (amount1Received, amount0Received); + } + } + + /// @notice receive extra tokens from user if specifies in arguments and normalize all the WETH to native ETH + function batchAndNormalizeTokens(Currency currency0, Currency currency1, uint256 extraAmount0, uint256 extraAmount1) + internal + { + ERC20 token0 = ERC20(Currency.unwrap(currency0)); + ERC20 token1 = ERC20(Currency.unwrap(currency1)); + + if (extraAmount0 > 0) { + if (currency0.isNative() && msg.value == 0) { + // we assume that user wants to send WETH + SafeTransferLib.safeTransferFrom(ERC20(WETH9), msg.sender, address(this), extraAmount0); + } else if (!currency0.isNative()) { + SafeTransferLib.safeTransferFrom(token0, msg.sender, address(this), extraAmount0); + } + } + + /// @dev token1 cant be NATIVE + if (extraAmount1 > 0) { + SafeTransferLib.safeTransferFrom(token1, msg.sender, address(this), extraAmount1); + } + + if (extraAmount0 != 0 || extraAmount1 != 0) { + emit ExtraFundsAdded(address(token0), address(token1), extraAmount0, extraAmount1); + } + + // even if user sends native ETH, we still need to unwrap the part from source pool + if (currency0.isNative()) { + uint256 wethBalance = ERC20(WETH9).balanceOf(address(this)); + if (wethBalance > 0) IWETH9(WETH9).withdraw(wethBalance); + } + } + + /// @notice approve the maximum amount of token if the current allowance is insufficient for following operations + function approveMaxIfNeeded(Currency currency, address to, uint256 amount) internal { + ERC20 token = ERC20(Currency.unwrap(currency)); + if (token.allowance(address(this), to) >= amount) { + return; + } + SafeTransferLib.safeApprove(token, to, type(uint256).max); + } + + /// @notice Check and revert if tokens from both v2/v3 and v4 pair does not match + /// Return true if match but v2v3Token1 is WETH which should be ETH in v4 pair + /// @param v2v3Token0 token0 from v2/v3 pair + /// @param v2v3Token1 token1 from v2/v3 pair + /// @param v4Token0 token0 from v4 pair + /// @param v4Token1 token1 from v4 pair + /// @return shouldReversePair if the order of tokens from v2/v3 pair is different from v4 pair (only when WETH is involved) + function _checkIfTokenPairMatchAndOrder( + address v2v3Token0, + address v2v3Token1, + Currency v4Token0, + Currency v4Token1 + ) private view returns (bool shouldReversePair) { + if (v4Token0.isNative() && v2v3Token0 == WETH9) { + if (Currency.unwrap(v4Token1) != v2v3Token1) { + revert TOKEN_NOT_MATCH(); + } + } else if (v4Token0.isNative() && v2v3Token1 == WETH9) { + if (Currency.unwrap(v4Token1) != v2v3Token0) { + revert TOKEN_NOT_MATCH(); + } + shouldReversePair = true; + } else { + /// @dev the order of token0 and token1 is always sorted + /// v2: https://github.com/pancakeswap/pancake-swap-core-v2/blob/38aad83854a46a82ea0e31988ff3cddb2bffb71a/contracts/PancakeFactory.sol#L27 + /// v3: https://github.com/pancakeswap/pancake-v3-contracts/blob/5cc479f0c5a98966c74d94700057b8c3ca629afd/projects/v3-core/contracts/PancakeV3Factory.sol#L66 + if (Currency.unwrap(v4Token0) != v2v3Token0 || Currency.unwrap(v4Token1) != v2v3Token1) { + revert TOKEN_NOT_MATCH(); + } + } + } +} diff --git a/src/base/SelfPermitERC721.sol b/src/base/SelfPermitERC721.sol new file mode 100644 index 0000000..745ef1b --- /dev/null +++ b/src/base/SelfPermitERC721.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2024 PancakeSwap +pragma solidity ^0.8.19; + +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC721Permit} from "../pool-cl/interfaces/IERC721Permit.sol"; +import {ISelfPermitERC721} from "../interfaces/ISelfPermitERC721.sol"; + +/// @title Self Permit For ERC721 +/// @notice Functionality to call permit on any EIP-2612-compliant token for use in the route +/// @dev These functions are expected to be embedded in multicalls to allow EOAs to approve a contract and call a function +/// that requires an approval in a single transaction. +abstract contract SelfPermitERC721 is ISelfPermitERC721 { + /// @inheritdoc ISelfPermitERC721 + function selfPermitERC721(address token, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + public + payable + override + { + IERC721Permit(token).permit(address(this), tokenId, deadline, v, r, s); + } + + /// @inheritdoc ISelfPermitERC721 + function selfPermitERC721IfNecessary( + address token, + uint256 tokenId, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external payable override { + if ( + IERC721(token).getApproved(tokenId) != address(this) + && !IERC721(token).isApprovedForAll(IERC721(token).ownerOf(tokenId), address(this)) + ) { + selfPermitERC721(token, tokenId, deadline, v, r, s); + } + } +} diff --git a/src/interfaces/IBaseMigrator.sol b/src/interfaces/IBaseMigrator.sol new file mode 100644 index 0000000..445b706 --- /dev/null +++ b/src/interfaces/IBaseMigrator.sol @@ -0,0 +1,51 @@ +// 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"; +import {IPeripheryImmutableState} from "./IPeripheryImmutableState.sol"; +import {IMulticall} from "./IMulticall.sol"; +import {ISelfPermit} from "./ISelfPermit.sol"; +import {ISelfPermitERC721} from "./ISelfPermitERC721.sol"; + +interface IBaseMigrator is IPeripheryImmutableState, IMulticall, ISelfPermit, ISelfPermitERC721 { + error TOKEN_NOT_MATCH(); + error INVALID_ETHER_SENDER(); + error INSUFFICIENT_AMOUNTS_RECEIVED(); + error NOT_TOKEN_OWNER(); + + /// @notice The event emitted when extra funds are added to the migrator + /// @param currency0 the address of the token0 + /// @param currency1 the address of the token1 + /// @param extraAmount0 the amount of extra token0 + /// @param extraAmount1 the amount of extra token1 + event ExtraFundsAdded(address currency0, address currency1, uint256 extraAmount0, uint256 extraAmount1); + + /// @notice Parameters for removing liquidity from v2 + struct V2PoolParams { + // the PancakeSwap v2-compatible pair + address pair; + // the amount of v2 lp token to be withdrawn + uint256 migrateAmount; + // the amount of token0 and token1 to be received after burning must be no less than these + uint256 amount0Min; + uint256 amount1Min; + } + + /// @notice Parameters for removing liquidity from v3 + struct V3PoolParams { + // the PancakeSwap v3-compatible NFP + address nfp; + uint256 tokenId; + uint128 liquidity; + uint256 amount0Min; + uint256 amount1Min; + // decide whether to collect fee + bool collectFee; + uint256 deadline; + } + + /// @notice refund native ETH to caller + /// This is useful when the caller sends more ETH then he specifies in arguments + function refundETH() external payable; +} diff --git a/src/interfaces/ISelfPermitERC721.sol b/src/interfaces/ISelfPermitERC721.sol new file mode 100644 index 0000000..29dd736 --- /dev/null +++ b/src/interfaces/ISelfPermitERC721.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +/// @title Self Permit For ERC721 +/// @notice Functionality to call permit on any EIP-2612-compliant token +/// This is for PancakeSwapV3 styled Nonfungible Position Manager which supports permit extension +interface ISelfPermitERC721 { + /// @notice Permits this contract to spend a given position token from `msg.sender` + /// @dev The `owner` is always msg.sender and the `spender` is always address(this). + /// @param token The address of the token spent + /// @param tokenId The token ID of the token spent + /// @param deadline A timestamp, the current blocktime must be less than or equal to this timestamp + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function selfPermitERC721(address token, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external + payable; + + /// @notice Permits this contract to spend a given token from `msg.sender` + /// @dev The `owner` is always msg.sender and the `spender` is always address(this). + /// Please always use selfPermitERC721IfNecessary if possible prevent calls from failing due to a frontrun of a call to #selfPermitERC721. + /// For details check https://github.com/pancakeswap/pancake-v4-periphery/pull/62#discussion_r1675410282 + /// @param token The address of the token spent + /// @param tokenId The token ID of the token spent + /// @param deadline A timestamp, the current blocktime must be less than or equal to this timestamp + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function selfPermitERC721IfNecessary( + address token, + uint256 tokenId, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external payable; +} diff --git a/src/interfaces/external/IPancakePair.sol b/src/interfaces/external/IPancakePair.sol new file mode 100644 index 0000000..99d5452 --- /dev/null +++ b/src/interfaces/external/IPancakePair.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.5.0; + +/// @notice Copying from PancakeSwap V2 Pair +/// https://github.com/pancakeswap/pancake-swap-core-v2/blob/master/contracts/interfaces/IPancakePair.sol +interface IPancakePair { + event Approval(address indexed owner, address indexed spender, uint256 value); + event Transfer(address indexed from, address indexed to, uint256 value); + + function name() external pure returns (string memory); + function symbol() external pure returns (string memory); + function decimals() external pure returns (uint8); + function totalSupply() external view returns (uint256); + function balanceOf(address owner) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); + + function approve(address spender, uint256 value) external returns (bool); + function transfer(address to, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) external returns (bool); + + function DOMAIN_SEPARATOR() external view returns (bytes32); + function PERMIT_TYPEHASH() external pure returns (bytes32); + function nonces(address owner) external view returns (uint256); + + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external; + + event Mint(address indexed sender, uint256 amount0, uint256 amount1); + event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to); + event Swap( + address indexed sender, + uint256 amount0In, + uint256 amount1In, + uint256 amount0Out, + uint256 amount1Out, + address indexed to + ); + event Sync(uint112 reserve0, uint112 reserve1); + + function MINIMUM_LIQUIDITY() external pure returns (uint256); + function factory() external view returns (address); + function token0() external view returns (address); + function token1() external view returns (address); + function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); + function price0CumulativeLast() external view returns (uint256); + function price1CumulativeLast() external view returns (uint256); + function kLast() external view returns (uint256); + + function mint(address to) external returns (uint256 liquidity); + function burn(address to) external returns (uint256 amount0, uint256 amount1); + function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external; + function skim(address to) external; + function sync() external; + + function initialize(address, address) external; +} diff --git a/src/interfaces/external/IV3NonfungiblePositionManager.sol b/src/interfaces/external/IV3NonfungiblePositionManager.sol new file mode 100644 index 0000000..6c5fe1d --- /dev/null +++ b/src/interfaces/external/IV3NonfungiblePositionManager.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +import {IERC721Permit} from "../../pool-cl/interfaces/IERC721Permit.sol"; + +/// @title Non-fungible token for positions +/// @notice Wraps PancakeSwap V3 positions in a non-fungible token interface which allows for them to be transferred +/// and authorized. Copying from PancakeSwap-V3 +/// https://github.com/pancakeswap/pancake-v3-contracts/blob/main/projects/v3-periphery/contracts/interfaces/INonfungiblePositionManager.sol +interface IV3NonfungiblePositionManager is IERC721Permit { + /// @notice Emitted when liquidity is increased for a position NFT + /// @dev Also emitted when a token is minted + /// @param tokenId The ID of the token for which liquidity was increased + /// @param liquidity The amount by which liquidity for the NFT position was increased + /// @param amount0 The amount of token0 that was paid for the increase in liquidity + /// @param amount1 The amount of token1 that was paid for the increase in liquidity + event IncreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + /// @notice Emitted when liquidity is decreased for a position NFT + /// @param tokenId The ID of the token for which liquidity was decreased + /// @param liquidity The amount by which liquidity for the NFT position was decreased + /// @param amount0 The amount of token0 that was accounted for the decrease in liquidity + /// @param amount1 The amount of token1 that was accounted for the decrease in liquidity + event DecreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + /// @notice Emitted when tokens are collected for a position NFT + /// @dev The amounts reported may not be exactly equivalent to the amounts transferred, due to rounding behavior + /// @param tokenId The ID of the token for which underlying tokens were collected + /// @param recipient The address of the account that received the collected tokens + /// @param amount0 The amount of token0 owed to the position that was collected + /// @param amount1 The amount of token1 owed to the position that was collected + event Collect(uint256 indexed tokenId, address recipient, uint256 amount0, uint256 amount1); + + /// @notice Returns the position information associated with a given token ID. + /// @dev Throws if the token ID is not valid. + /// @param tokenId The ID of the token that represents the position + /// @return nonce The nonce for permits + /// @return operator The address that is approved for spending + /// @return token0 The address of the token0 for a specific pool + /// @return token1 The address of the token1 for a specific pool + /// @return fee The fee associated with the pool + /// @return tickLower The lower end of the tick range for the position + /// @return tickUpper The higher end of the tick range for the position + /// @return liquidity The liquidity of the position + /// @return feeGrowthInside0LastX128 The fee growth of token0 as of the last action on the individual position + /// @return feeGrowthInside1LastX128 The fee growth of token1 as of the last action on the individual position + /// @return tokensOwed0 The uncollected amount of token0 owed to the position as of the last computation + /// @return tokensOwed1 The uncollected amount of token1 owed to the position as of the last computation + function positions(uint256 tokenId) + external + view + returns ( + uint96 nonce, + address operator, + address token0, + address token1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1 + ); + + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + /// @notice Creates a new position wrapped in a NFT + /// @dev Call this when the pool does exist and is initialized. Note that if the pool is created but not initialized + /// a method does not exist, i.e. the pool is assumed to be initialized. + /// @param params The params necessary to mint a position, encoded as `MintParams` in calldata + /// @return tokenId The ID of the token that represents the minted position + /// @return liquidity The amount of liquidity for this position + /// @return amount0 The amount of token0 + /// @return amount1 The amount of token1 + function mint(MintParams calldata params) + external + payable + returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); + + struct IncreaseLiquidityParams { + uint256 tokenId; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + /// @notice Increases the amount of liquidity in a position, with tokens paid by the `msg.sender` + /// @param params tokenId The ID of the token for which liquidity is being increased, + /// amount0Desired The desired amount of token0 to be spent, + /// amount1Desired The desired amount of token1 to be spent, + /// amount0Min The minimum amount of token0 to spend, which serves as a slippage check, + /// amount1Min The minimum amount of token1 to spend, which serves as a slippage check, + /// deadline The time by which the transaction must be included to effect the change + /// @return liquidity The new liquidity amount as a result of the increase + /// @return amount0 The amount of token0 to acheive resulting liquidity + /// @return amount1 The amount of token1 to acheive resulting liquidity + function increaseLiquidity(IncreaseLiquidityParams calldata params) + external + payable + returns (uint128 liquidity, uint256 amount0, uint256 amount1); + + struct DecreaseLiquidityParams { + uint256 tokenId; + uint128 liquidity; + uint256 amount0Min; + uint256 amount1Min; + uint256 deadline; + } + + /// @notice Decreases the amount of liquidity in a position and accounts it to the position + /// @param params tokenId The ID of the token for which liquidity is being decreased, + /// amount The amount by which liquidity will be decreased, + /// amount0Min The minimum amount of token0 that should be accounted for the burned liquidity, + /// amount1Min The minimum amount of token1 that should be accounted for the burned liquidity, + /// deadline The time by which the transaction must be included to effect the change + /// @return amount0 The amount of token0 accounted to the position's tokens owed + /// @return amount1 The amount of token1 accounted to the position's tokens owed + function decreaseLiquidity(DecreaseLiquidityParams calldata params) + external + payable + returns (uint256 amount0, uint256 amount1); + + struct CollectParams { + uint256 tokenId; + address recipient; + uint128 amount0Max; + uint128 amount1Max; + } + + /// @notice Collects up to a maximum amount of fees owed to a specific position to the recipient + /// @param params tokenId The ID of the NFT for which tokens are being collected, + /// recipient The account that should receive the tokens, + /// amount0Max The maximum amount of token0 to collect, + /// amount1Max The maximum amount of token1 to collect + /// @return amount0 The amount of fees collected in token0 + /// @return amount1 The amount of fees collected in token1 + function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1); + + /// @notice Burns a token ID, which deletes it from the NFT contract. The token must have 0 liquidity and all tokens + /// must be collected first. + /// @param tokenId The ID of the token that is being burned + function burn(uint256 tokenId) external payable; + + function createAndInitializePoolIfNecessary(address token0, address token1, uint24 fee, uint160 sqrtPriceX96) + external + payable + returns (address pool); +} diff --git a/src/pool-bin/BinMigrator.sol b/src/pool-bin/BinMigrator.sol new file mode 100644 index 0000000..8cb6e6f --- /dev/null +++ b/src/pool-bin/BinMigrator.sol @@ -0,0 +1,155 @@ +// 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, IV3NonfungiblePositionManager} from "../base/BaseMigrator.sol"; +import {IBinMigrator, PoolKey} from "./interfaces/IBinMigrator.sol"; +import {IBinFungiblePositionManager} from "./interfaces/IBinFungiblePositionManager.sol"; +import {Currency} from "pancake-v4-core/src/types/Currency.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +contract BinMigrator is IBinMigrator, BaseMigrator { + IBinFungiblePositionManager public immutable binFungiblePositionManager; + + constructor(address _WETH9, address _binFungiblePositionManager) BaseMigrator(_WETH9) { + binFungiblePositionManager = IBinFungiblePositionManager(_binFungiblePositionManager); + } + + /// @inheritdoc IBinMigrator + function migrateFromV2( + V2PoolParams calldata v2PoolParams, + V4BinPoolParams calldata v4PoolParams, + uint256 extraAmount0, + uint256 extraAmount1 + ) external payable override { + bool shouldReversePair = checkTokensOrderAndMatchFromV2( + v2PoolParams.pair, v4PoolParams.poolKey.currency0, v4PoolParams.poolKey.currency1 + ); + (uint256 amount0Received, uint256 amount1Received) = withdrawLiquidityFromV2(v2PoolParams, shouldReversePair); + + /// @notice if user mannually specify the price range, they might need to send extra token + batchAndNormalizeTokens( + v4PoolParams.poolKey.currency0, v4PoolParams.poolKey.currency1, extraAmount0, extraAmount1 + ); + + uint256 amount0Input = amount0Received + extraAmount0; + uint256 amount1Input = amount1Received + extraAmount1; + IBinFungiblePositionManager.AddLiquidityParams memory addLiquidityParams = IBinFungiblePositionManager + .AddLiquidityParams({ + poolKey: v4PoolParams.poolKey, + amount0: SafeCast.toUint128(amount0Input), + amount1: SafeCast.toUint128(amount1Input), + amount0Min: v4PoolParams.amount0Min, + amount1Min: v4PoolParams.amount1Min, + activeIdDesired: v4PoolParams.activeIdDesired, + idSlippage: v4PoolParams.idSlippage, + deltaIds: v4PoolParams.deltaIds, + distributionX: v4PoolParams.distributionX, + distributionY: v4PoolParams.distributionY, + to: v4PoolParams.to, + deadline: v4PoolParams.deadline + }); + (uint256 amount0Consumed, uint256 amount1Consumed,,) = _addLiquidityToTargetPool(addLiquidityParams); + + // refund if necessary, ETH is supported by CurrencyLib + unchecked { + if (amount0Input > amount0Consumed) { + v4PoolParams.poolKey.currency0.transfer(v4PoolParams.to, amount0Input - amount0Consumed); + } + if (amount1Input > amount1Consumed) { + v4PoolParams.poolKey.currency1.transfer(v4PoolParams.to, amount1Input - amount1Consumed); + } + } + } + + /// @inheritdoc IBinMigrator + function migrateFromV3( + V3PoolParams calldata v3PoolParams, + V4BinPoolParams calldata v4PoolParams, + uint256 extraAmount0, + uint256 extraAmount1 + ) external payable override { + bool shouldReversePair = checkTokensOrderAndMatchFromV3( + v3PoolParams.nfp, v3PoolParams.tokenId, v4PoolParams.poolKey.currency0, v4PoolParams.poolKey.currency1 + ); + (uint256 amount0Received, uint256 amount1Received) = withdrawLiquidityFromV3(v3PoolParams, shouldReversePair); + + /// @notice if user mannually specify the price range, they need to send extra token + batchAndNormalizeTokens( + v4PoolParams.poolKey.currency0, v4PoolParams.poolKey.currency1, extraAmount0, extraAmount1 + ); + + uint256 amount0Input = amount0Received + extraAmount0; + uint256 amount1Input = amount1Received + extraAmount1; + IBinFungiblePositionManager.AddLiquidityParams memory addLiquidityParams = IBinFungiblePositionManager + .AddLiquidityParams({ + poolKey: v4PoolParams.poolKey, + amount0: SafeCast.toUint128(amount0Input), + amount1: SafeCast.toUint128(amount1Input), + amount0Min: v4PoolParams.amount0Min, + amount1Min: v4PoolParams.amount1Min, + activeIdDesired: v4PoolParams.activeIdDesired, + idSlippage: v4PoolParams.idSlippage, + deltaIds: v4PoolParams.deltaIds, + distributionX: v4PoolParams.distributionX, + distributionY: v4PoolParams.distributionY, + to: v4PoolParams.to, + deadline: v4PoolParams.deadline + }); + (uint256 amount0Consumed, uint256 amount1Consumed,,) = _addLiquidityToTargetPool(addLiquidityParams); + + // refund if necessary, ETH is supported by CurrencyLib + unchecked { + if (amount0Input > amount0Consumed) { + v4PoolParams.poolKey.currency0.transfer(v4PoolParams.to, amount0Input - amount0Consumed); + } + if (amount1Input > amount1Consumed) { + v4PoolParams.poolKey.currency1.transfer(v4PoolParams.to, amount1Input - amount1Consumed); + } + } + } + + /// @dev adding liquidity to target bin pool, collect surplus ETH if necessary + /// @param params bin position manager add liquidity params + /// @return amount0Consumed the actual amount of token0 consumed + /// @return amount1Consumed the actual amount of token1 consumed + /// @return tokenIds the list of the id of the position token minted + /// @return liquidityMinted the list of the amount of the position token minted + function _addLiquidityToTargetPool(IBinFungiblePositionManager.AddLiquidityParams memory params) + internal + returns ( + uint128 amount0Consumed, + uint128 amount1Consumed, + uint256[] memory tokenIds, + uint256[] memory liquidityMinted + ) + { + /// @dev currency1 cant be NATIVE + bool nativePair = params.poolKey.currency0.isNative(); + if (!nativePair) { + approveMaxIfNeeded(params.poolKey.currency0, address(binFungiblePositionManager), params.amount0); + } + approveMaxIfNeeded(params.poolKey.currency1, address(binFungiblePositionManager), params.amount1); + + (amount0Consumed, amount1Consumed, tokenIds, liquidityMinted) = + binFungiblePositionManager.addLiquidity{value: nativePair ? params.amount0 : 0}(params); + + // receive surplus ETH from positionManager + if (nativePair && params.amount0 > amount0Consumed) { + binFungiblePositionManager.refundETH(); + } + } + + /// @inheritdoc IBinMigrator + /// @notice Planned to be batched with migration operations through multicall to save gas + function initialize(PoolKey memory poolKey, uint24 activeId, bytes calldata hookData) external payable override { + return binFungiblePositionManager.initialize(poolKey, activeId, hookData); + } + + receive() external payable { + if (msg.sender != address(binFungiblePositionManager) && msg.sender != WETH9) { + revert INVALID_ETHER_SENDER(); + } + } +} diff --git a/src/pool-bin/interfaces/IBinFungiblePositionManager.sol b/src/pool-bin/interfaces/IBinFungiblePositionManager.sol index 81e32fa..4a6a2b6 100644 --- a/src/pool-bin/interfaces/IBinFungiblePositionManager.sol +++ b/src/pool-bin/interfaces/IBinFungiblePositionManager.sol @@ -10,8 +10,10 @@ import {BalanceDelta} from "pancake-v4-core/src/types/BalanceDelta.sol"; import {PoolKey} from "pancake-v4-core/src/types/PoolKey.sol"; import {PoolId} from "pancake-v4-core/src/types/PoolId.sol"; import {IBinFungibleToken} from "./IBinFungibleToken.sol"; +import {IPeripheryPayments} from "../../interfaces/IPeripheryPayments.sol"; +import {IMulticall} from "../../interfaces/IMulticall.sol"; -interface IBinFungiblePositionManager is IBinFungibleToken { +interface IBinFungiblePositionManager is IBinFungibleToken, IPeripheryPayments, IMulticall { error OnlyVaultCaller(); error IdOverflows(int256); error IdDesiredOverflows(uint24); diff --git a/src/pool-bin/interfaces/IBinMigrator.sol b/src/pool-bin/interfaces/IBinMigrator.sol new file mode 100644 index 0000000..363ac5c --- /dev/null +++ b/src/pool-bin/interfaces/IBinMigrator.sol @@ -0,0 +1,63 @@ +// 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"; +import {IBaseMigrator} from "../../interfaces/IBaseMigrator.sol"; +import {IV3NonfungiblePositionManager} from "../../interfaces/external/IV3NonfungiblePositionManager.sol"; + +interface IBinMigrator is IBaseMigrator { + /// @notice same fields as IBinFungiblePositionManager.AddLiquidityParams + /// except amount0/amount1 which will be calculated by migrator + struct V4BinPoolParams { + PoolKey poolKey; + // uint128 amount0; + // uint128 amount1; + uint128 amount0Min; + uint128 amount1Min; + uint256 activeIdDesired; + uint256 idSlippage; + int256[] deltaIds; + uint256[] distributionX; + uint256[] distributionY; + address to; + uint256 deadline; + } + + /// @notice Migrate liquidity from v2 to v4 + /// @param v2PoolParams ncessary info for removing liqudity the source v2 pool + /// @param v4PoolParams necessary info for adding liquidity the target v4 bin-pool + /// @param extraAmount0 the extra amount of token0 that user wants to add (optional, usually 0) + /// if pool token0 is ETH and msg.value == 0, WETH will be taken from sender. + /// Otherwise if pool token0 is ETH and msg.value !=0, method will assume user have sent extraAmount0 in msg.value + /// @param extraAmount1 the extra amount of token1 that user wants to add (optional, usually 0) + function migrateFromV2( + V2PoolParams calldata v2PoolParams, + V4BinPoolParams calldata v4PoolParams, + // extra funds to be added + uint256 extraAmount0, + uint256 extraAmount1 + ) external payable; + + /// @notice Migrate liquidity from v3 to v4 + /// @param v3PoolParams ncessary info for removing liqudity the source v3 pool + /// @param v4PoolParams necessary info for adding liquidity the target v4 bin-pool + /// @param extraAmount0 the extra amount of token0 that user wants to add (optional, usually 0) + /// if pool token0 is ETH and msg.value == 0, WETH will be taken from sender. + /// Otherwise if pool token0 is ETH and msg.value !=0, method will assume user have sent extraAmount0 in msg.value + /// @param extraAmount1 the extra amount of token1 that user wants to add (optional, usually 0) + function migrateFromV3( + V3PoolParams calldata v3PoolParams, + V4BinPoolParams calldata v4PoolParams, + // extra funds to be added + uint256 extraAmount0, + uint256 extraAmount1 + ) external payable; + + /// @notice Initialize a pool for a given pool key, the function will forwards the call to the BinPoolManager + /// @dev Call this when the pool does not exist and is not initialized + /// @param poolKey The pool key + /// @param activeId The active id of the pool + /// @param hookData Hook data for the pool + function initialize(PoolKey memory poolKey, uint24 activeId, bytes calldata hookData) external payable; +} diff --git a/src/pool-cl/CLMigrator.sol b/src/pool-cl/CLMigrator.sol new file mode 100644 index 0000000..ac6bdb5 --- /dev/null +++ b/src/pool-cl/CLMigrator.sol @@ -0,0 +1,149 @@ +// 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, IV3NonfungiblePositionManager} from "../base/BaseMigrator.sol"; +import {ICLMigrator, PoolKey} from "./interfaces/ICLMigrator.sol"; +import {INonfungiblePositionManager} from "./interfaces/INonfungiblePositionManager.sol"; +import {Currency} from "pancake-v4-core/src/types/Currency.sol"; + +contract CLMigrator is ICLMigrator, BaseMigrator { + INonfungiblePositionManager public immutable nonfungiblePositionManager; + + constructor(address _WETH9, address _nonfungiblePositionManager) BaseMigrator(_WETH9) { + nonfungiblePositionManager = INonfungiblePositionManager(_nonfungiblePositionManager); + } + + /// @inheritdoc ICLMigrator + function migrateFromV2( + V2PoolParams calldata v2PoolParams, + V4CLPoolParams calldata v4PoolParams, + uint256 extraAmount0, + uint256 extraAmount1 + ) external payable override { + bool shouldReversePair = checkTokensOrderAndMatchFromV2( + v2PoolParams.pair, v4PoolParams.poolKey.currency0, v4PoolParams.poolKey.currency1 + ); + + (uint256 amount0Received, uint256 amount1Received) = withdrawLiquidityFromV2(v2PoolParams, shouldReversePair); + + /// @notice if user mannually specify the price range, they might need to send extra token + batchAndNormalizeTokens( + v4PoolParams.poolKey.currency0, v4PoolParams.poolKey.currency1, extraAmount0, extraAmount1 + ); + + uint256 amount0In = amount0Received + extraAmount0; + uint256 amount1In = amount1Received + extraAmount1; + INonfungiblePositionManager.MintParams memory mintParams = INonfungiblePositionManager.MintParams({ + poolKey: v4PoolParams.poolKey, + tickLower: v4PoolParams.tickLower, + tickUpper: v4PoolParams.tickUpper, + salt: v4PoolParams.salt, + amount0Desired: amount0In, + amount1Desired: amount1In, + amount0Min: v4PoolParams.amount0Min, + amount1Min: v4PoolParams.amount1Min, + recipient: v4PoolParams.recipient, + deadline: v4PoolParams.deadline + }); + (,, uint256 amount0Consumed, uint256 amount1Consumed) = _addLiquidityToTargetPool(mintParams); + + // refund if necessary, ETH is supported by CurrencyLib + unchecked { + if (amount0In > amount0Consumed) { + v4PoolParams.poolKey.currency0.transfer(v4PoolParams.recipient, amount0In - amount0Consumed); + } + if (amount1In > amount1Consumed) { + v4PoolParams.poolKey.currency1.transfer(v4PoolParams.recipient, amount1In - amount1Consumed); + } + } + } + + /// @inheritdoc ICLMigrator + function migrateFromV3( + V3PoolParams calldata v3PoolParams, + V4CLPoolParams calldata v4PoolParams, + uint256 extraAmount0, + uint256 extraAmount1 + ) external payable override { + bool shouldReversePair = checkTokensOrderAndMatchFromV3( + v3PoolParams.nfp, v3PoolParams.tokenId, v4PoolParams.poolKey.currency0, v4PoolParams.poolKey.currency1 + ); + (uint256 amount0Received, uint256 amount1Received) = withdrawLiquidityFromV3(v3PoolParams, shouldReversePair); + + /// @notice if user mannually specify the price range, they need to send extra token + batchAndNormalizeTokens( + v4PoolParams.poolKey.currency0, v4PoolParams.poolKey.currency1, extraAmount0, extraAmount1 + ); + + uint256 amount0In = amount0Received + extraAmount0; + uint256 amount1In = amount1Received + extraAmount1; + INonfungiblePositionManager.MintParams memory mintParams = INonfungiblePositionManager.MintParams({ + poolKey: v4PoolParams.poolKey, + tickLower: v4PoolParams.tickLower, + tickUpper: v4PoolParams.tickUpper, + salt: v4PoolParams.salt, + amount0Desired: amount0In, + amount1Desired: amount1In, + amount0Min: v4PoolParams.amount0Min, + amount1Min: v4PoolParams.amount1Min, + recipient: v4PoolParams.recipient, + deadline: v4PoolParams.deadline + }); + (,, uint256 amount0Consumed, uint256 amount1Consumed) = _addLiquidityToTargetPool(mintParams); + + // refund if necessary, ETH is supported by CurrencyLib + unchecked { + if (amount0In > amount0Consumed) { + v4PoolParams.poolKey.currency0.transfer(v4PoolParams.recipient, amount0In - amount0Consumed); + } + if (amount1In > amount1Consumed) { + v4PoolParams.poolKey.currency1.transfer(v4PoolParams.recipient, amount1In - amount1Consumed); + } + } + } + + /// @dev adding liquidity to target cl pool, collect surplus ETH if necessary + /// @param params cl position manager add liquidity params + /// @return tokenId the id of the newly minted position token + /// @return liquidity the amount of liquidity minted + /// @return amount0Consumed the actual amount of token0 consumed + /// @return amount1Consumed the actual amount of token1 consumed + function _addLiquidityToTargetPool(INonfungiblePositionManager.MintParams memory params) + internal + returns (uint256 tokenId, uint128 liquidity, uint256 amount0Consumed, uint256 amount1Consumed) + { + /// @dev currency1 cant be NATIVE + bool nativePair = params.poolKey.currency0.isNative(); + if (!nativePair) { + approveMaxIfNeeded(params.poolKey.currency0, address(nonfungiblePositionManager), params.amount0Desired); + } + approveMaxIfNeeded(params.poolKey.currency1, address(nonfungiblePositionManager), params.amount1Desired); + + (tokenId, liquidity, amount0Consumed, amount1Consumed) = + nonfungiblePositionManager.mint{value: nativePair ? params.amount0Desired : 0}(params); + + // receive surplus ETH from positionManager + if (nativePair && params.amount0Desired > amount0Consumed) { + nonfungiblePositionManager.refundETH(); + } + } + + /// @inheritdoc ICLMigrator + /// @notice Planned to be batched with migration operations through multicall to save gas + function initialize(PoolKey memory poolKey, uint160 sqrtPriceX96, bytes calldata hookData) + external + payable + override + returns (int24 tick) + { + return nonfungiblePositionManager.initialize(poolKey, sqrtPriceX96, hookData); + } + + receive() external payable { + if (msg.sender != address(nonfungiblePositionManager) && msg.sender != WETH9) { + revert INVALID_ETHER_SENDER(); + } + } +} diff --git a/src/pool-cl/interfaces/ICLMigrator.sol b/src/pool-cl/interfaces/ICLMigrator.sol new file mode 100644 index 0000000..c81db69 --- /dev/null +++ b/src/pool-cl/interfaces/ICLMigrator.sol @@ -0,0 +1,64 @@ +// 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"; +import {IBaseMigrator} from "../../interfaces/IBaseMigrator.sol"; +import {IV3NonfungiblePositionManager} from "../../interfaces/external/IV3NonfungiblePositionManager.sol"; +import {INonfungiblePositionManager} from "./INonfungiblePositionManager.sol"; + +interface ICLMigrator is IBaseMigrator { + /// @notice same fields as INonfungiblePositionManager.MintParams + /// except amount0Desired/amount1Desired which will be calculated by migrator + struct V4CLPoolParams { + PoolKey poolKey; + int24 tickLower; + int24 tickUpper; + bytes32 salt; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + /// @notice Migrate liquidity from v2 to v4 + /// @param v2PoolParams ncessary info for removing liqudity the source v2 pool + /// @param v4PoolParams necessary info for adding liquidity the target v4 cl-pool + /// @param extraAmount0 the extra amount of token0 that user wants to add (optional, usually 0) + /// if pool token0 is ETH and msg.value == 0, WETH will be taken from sender. + /// Otherwise if pool token0 is ETH and msg.value !=0, method will assume user have sent extraAmount0 in msg.value + /// @param extraAmount1 the extra amount of token1 that user wants to add (optional, usually 0) + function migrateFromV2( + V2PoolParams calldata v2PoolParams, + V4CLPoolParams calldata v4PoolParams, + // extra funds to be added + uint256 extraAmount0, + uint256 extraAmount1 + ) external payable; + + /// @notice Migrate liquidity from v3 to v4 + /// @param v3PoolParams ncessary info for removing liqudity the source v3 pool + /// @param v4PoolParams necessary info for adding liquidity the target v4 cl-pool + /// @param extraAmount0 the extra amount of token0 that user wants to add (optional, usually 0) + /// if pool token0 is ETH and msg.value == 0, WETH will be taken from sender. + /// Otherwise if pool token0 is ETH and msg.value !=0, method will assume user have sent extraAmount0 in msg.value + /// @param extraAmount1 the extra amount of token1 that user wants to add (optional, usually 0) + function migrateFromV3( + V3PoolParams calldata v3PoolParams, + V4CLPoolParams calldata v4PoolParams, + // extra funds to be added + uint256 extraAmount0, + uint256 extraAmount1 + ) external payable; + + /// @notice Initialize a pool for a given pool key, the function will forwards the call to the CLPoolManager + /// @dev Call this when the pool does not exist and is not initialized. + /// @param poolKey The pool key + /// @param sqrtPriceX96 The initial sqrt price of the pool + /// @param hookData Hook data for the pool + /// @return tick Pool tick + function initialize(PoolKey memory poolKey, uint160 sqrtPriceX96, bytes calldata hookData) + external + payable + returns (int24 tick); +} diff --git a/src/pool-cl/interfaces/INonfungiblePositionManager.sol b/src/pool-cl/interfaces/INonfungiblePositionManager.sol index b794c57..6bfe935 100644 --- a/src/pool-cl/interfaces/INonfungiblePositionManager.sol +++ b/src/pool-cl/interfaces/INonfungiblePositionManager.sol @@ -11,16 +11,20 @@ import {IERC721Metadata} from "@openzeppelin/contracts/token/ERC721/extensions/I import {IERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol"; import {IERC721Permit} from "./IERC721Permit.sol"; import {ICLPeripheryImmutableState} from "./ICLPeripheryImmutableState.sol"; +import {IPeripheryPayments} from "../../interfaces/IPeripheryPayments.sol"; +import {IMulticall} from "../../interfaces/IMulticall.sol"; /// @title Non-fungible token for positions /// @notice Wraps PancakeSwap V4 positions in a non-fungible token interface which allows for them to be transferred /// and authorized. interface INonfungiblePositionManager is + IPeripheryPayments, ILockCallback, ICLPeripheryImmutableState, IERC721Metadata, IERC721Enumerable, - IERC721Permit + IERC721Permit, + IMulticall { error NotOwnerOrOperator(); error InvalidLiquidityDecreaseAmount(); diff --git a/test/bin/pcsV2Factory.bytecode b/test/bin/pcsV2Factory.bytecode new file mode 100644 index 0000000..bc34a14 Binary files /dev/null and b/test/bin/pcsV2Factory.bytecode differ diff --git a/test/bin/pcsV3Deployer.bytecode b/test/bin/pcsV3Deployer.bytecode new file mode 100644 index 0000000..d7f4d60 Binary files /dev/null and b/test/bin/pcsV3Deployer.bytecode differ diff --git a/test/bin/pcsV3Factory.bytecode b/test/bin/pcsV3Factory.bytecode new file mode 100644 index 0000000..4fe1fb8 Binary files /dev/null and b/test/bin/pcsV3Factory.bytecode differ diff --git a/test/bin/pcsV3Nfpm.bytecode b/test/bin/pcsV3Nfpm.bytecode new file mode 100644 index 0000000..38b6041 Binary files /dev/null and b/test/bin/pcsV3Nfpm.bytecode differ diff --git a/test/bin/uniV2Factory.bytecode b/test/bin/uniV2Factory.bytecode new file mode 100644 index 0000000..aaf4165 Binary files /dev/null and b/test/bin/uniV2Factory.bytecode differ diff --git a/test/bin/uniV3Factory.bytecode b/test/bin/uniV3Factory.bytecode new file mode 100644 index 0000000..0f35e7d Binary files /dev/null and b/test/bin/uniV3Factory.bytecode differ diff --git a/test/bin/uniV3Nfpm.bytecode b/test/bin/uniV3Nfpm.bytecode new file mode 100644 index 0000000..a7618b1 Binary files /dev/null and b/test/bin/uniV3Nfpm.bytecode differ diff --git a/test/helpers/OldVersionHelper.sol b/test/helpers/OldVersionHelper.sol new file mode 100644 index 0000000..3a9188a --- /dev/null +++ b/test/helpers/OldVersionHelper.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; + +contract OldVersionHelper is Test { + function createContractThroughBytecode(string memory path) internal returns (address deployedAddr) { + bytes memory bytecode = vm.readFileBinary(path); + assembly { + deployedAddr := create(0, add(bytecode, 0x20), mload(bytecode)) + } + } + + function createContractThroughBytecode(string memory path, bytes32 arg0) internal returns (address deployedAddr) { + bytes memory bytecode = vm.readFileBinary(path); + assembly { + // override constructor arguments + // posOfBytecode + 0x20 + length - 0x20 + let constructorArgStart := add(mload(bytecode), bytecode) + mstore(constructorArgStart, arg0) + deployedAddr := create(0, add(bytecode, 0x20), mload(bytecode)) + } + } + + function createContractThroughBytecode(string memory path, bytes32 arg0, bytes32 arg1, bytes32 arg2) + internal + returns (address deployedAddr) + { + bytes memory bytecode = vm.readFileBinary(path); + assembly { + // override constructor arguments + // posOfBytecode + 0x20 + length - 0x20 * 3 + let constructorArgStart := sub(add(mload(bytecode), bytecode), 0x40) + mstore(constructorArgStart, arg0) + mstore(add(constructorArgStart, 0x20), arg1) + mstore(add(constructorArgStart, 0x40), arg2) + // create(value, offset, size) + deployedAddr := create(0, add(bytecode, 0x20), mload(bytecode)) + } + } + + function createContractThroughBytecode(string memory path, bytes32 arg0, bytes32 arg1, bytes32 arg2, bytes32 arg3) + internal + returns (address deployedAddr) + { + bytes memory bytecode = vm.readFileBinary(path); + assembly { + // override constructor arguments + // posOfBytecode + 0x20 + length - 0x20 * 4 + let constructorArgStart := sub(add(mload(bytecode), bytecode), 0x60) + mstore(constructorArgStart, arg0) + mstore(add(constructorArgStart, 0x20), arg1) + mstore(add(constructorArgStart, 0x40), arg2) + mstore(add(constructorArgStart, 0x60), arg3) + // create(value, offset, size) + deployedAddr := create(0, add(bytecode, 0x20), mload(bytecode)) + } + } + + function toBytes32(address addr) internal pure returns (bytes32) { + return bytes32(uint256(uint160(addr))); + } +} diff --git a/test/pool-bin/migrator/BinMigratorFromPancakeswapV2.t.sol b/test/pool-bin/migrator/BinMigratorFromPancakeswapV2.t.sol new file mode 100644 index 0000000..1469eb9 --- /dev/null +++ b/test/pool-bin/migrator/BinMigratorFromPancakeswapV2.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {BinMigratorFromV2} from "./BinMigratorFromV2.sol"; + +contract BinMigratorFromPancakeswapV2Test is BinMigratorFromV2 { + function _getBytecodePath() internal pure override returns (string memory) { + // Create a Pancakeswap V2 pair + // relative to the root of the project + // https://etherscan.io/address/0x1097053Fd2ea711dad45caCcc45EfF7548fCB362#code + return "./test/bin/pcsV2Factory.bytecode"; + } + + function _getContractName() internal pure override returns (string memory) { + return "BinMigratorFromPancakeswapV2Test"; + } +} diff --git a/test/pool-bin/migrator/BinMigratorFromPancakeswapV3.t.sol b/test/pool-bin/migrator/BinMigratorFromPancakeswapV3.t.sol new file mode 100644 index 0000000..b4a6f9a --- /dev/null +++ b/test/pool-bin/migrator/BinMigratorFromPancakeswapV3.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {BinMigratorFromV3} from "./BinMigratorFromV3.sol"; + +contract BinMigratorFromPancakeswapV3Test is BinMigratorFromV3 { + function _getDeployerBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0x41ff9AA7e16B8B1a8a8dc4f0eFacd93D02d071c9#code + return "./test/bin/pcsV3Deployer.bytecode"; + } + + function _getFactoryBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865#code + return "./test/bin/pcsV3Factory.bytecode"; + } + + function _getNfpmBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0x46A15B0b27311cedF172AB29E4f4766fbE7F4364#code + return "./test/bin/pcsV3Nfpm.bytecode"; + } + + function _getContractName() internal pure override returns (string memory) { + return "BinMigratorFromPancakeswapV3Test"; + } +} diff --git a/test/pool-bin/migrator/BinMigratorFromUniswapV2.t.sol b/test/pool-bin/migrator/BinMigratorFromUniswapV2.t.sol new file mode 100644 index 0000000..d0b28e4 --- /dev/null +++ b/test/pool-bin/migrator/BinMigratorFromUniswapV2.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {BinMigratorFromV2} from "./BinMigratorFromV2.sol"; + +contract BinMigratorFromUniswapV2Test is BinMigratorFromV2 { + function _getBytecodePath() internal pure override returns (string memory) { + // Create a Uniswap V2 pair + // relative to the root of the project + // https://etherscan.io/address/0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f#code + return "./test/bin/uniV2Factory.bytecode"; + } + + function _getContractName() internal pure override returns (string memory) { + return "BinMigratorFromUniswapV2Test"; + } +} diff --git a/test/pool-bin/migrator/BinMigratorFromUniswapV3.t.sol b/test/pool-bin/migrator/BinMigratorFromUniswapV3.t.sol new file mode 100644 index 0000000..c1b5825 --- /dev/null +++ b/test/pool-bin/migrator/BinMigratorFromUniswapV3.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {BinMigratorFromV3} from "./BinMigratorFromV3.sol"; + +contract BinMigratorFromUniswapV3Test is BinMigratorFromV3 { + function _getDeployerBytecodePath() internal pure override returns (string memory) { + return ""; + } + + function _getFactoryBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0x1F98431c8aD98523631AE4a59f267346ea31F984#code + return "./test/bin/uniV3Factory.bytecode"; + } + + function _getNfpmBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0xC36442b4a4522E871399CD717aBDD847Ab11FE88#code + return "./test/bin/uniV3Nfpm.bytecode"; + } + + function _getContractName() internal pure override returns (string memory) { + return "BinMigratorFromUniswapV3Test"; + } +} diff --git a/test/pool-bin/migrator/BinMigratorFromV2.sol b/test/pool-bin/migrator/BinMigratorFromV2.sol new file mode 100644 index 0000000..d03efe1 --- /dev/null +++ b/test/pool-bin/migrator/BinMigratorFromV2.sol @@ -0,0 +1,922 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {OldVersionHelper} from "../../helpers/OldVersionHelper.sol"; +import {IPancakePair} from "../../../src/interfaces/external/IPancakePair.sol"; +import {WETH} from "solmate/tokens/WETH.sol"; +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {BinMigrator} from "../../../src/pool-bin/BinMigrator.sol"; +import {IBinMigrator, IBaseMigrator} from "../../../src/pool-bin/interfaces/IBinMigrator.sol"; +import {BinFungiblePositionManager} from "../../../src/pool-bin/BinFungiblePositionManager.sol"; +import {Vault} from "pancake-v4-core/src/Vault.sol"; +import {BinPoolManager} from "pancake-v4-core/src/pool-bin/BinPoolManager.sol"; +import {PoolKey} from "pancake-v4-core/src/types/PoolKey.sol"; +import {BinPoolParametersHelper} from "pancake-v4-core/src/pool-bin/libraries/BinPoolParametersHelper.sol"; +import {Currency} from "pancake-v4-core/src/types/Currency.sol"; +import {IPoolManager} from "pancake-v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "pancake-v4-core/src/interfaces/IHooks.sol"; +import {PoolId, PoolIdLibrary} from "pancake-v4-core/src/types/PoolId.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {LiquidityParamsHelper, IBinFungiblePositionManager} from "../helpers/LiquidityParamsHelper.sol"; +import {BinTokenLibrary} from "../../../src/pool-bin/libraries/BinTokenLibrary.sol"; + +interface IPancakeV2LikePairFactory { + function createPair(address tokenA, address tokenB) external returns (address pair); +} + +abstract contract BinMigratorFromV2 is OldVersionHelper, LiquidityParamsHelper, GasSnapshot { + using BinPoolParametersHelper for bytes32; + using PoolIdLibrary for PoolKey; + using BinTokenLibrary for PoolId; + + // 1 tokenX = 1 tokenY + uint24 public constant ACTIVE_BIN_ID = 2 ** 23; + + WETH weth; + MockERC20 token0; + MockERC20 token1; + + Vault vault; + BinPoolManager poolManager; + BinFungiblePositionManager binFungiblePositionManager; + IBinMigrator migrator; + PoolKey poolKey; + PoolKey poolKeyWithoutNativeToken; + + IPancakeV2LikePairFactory v2Factory; + IPancakePair v2Pair; + IPancakePair v2PairWithoutNativeToken; + + function _getBytecodePath() internal pure virtual returns (string memory); + + function _getContractName() internal pure virtual returns (string memory); + + function setUp() public { + weth = new WETH(); + token0 = new MockERC20("Token0", "TKN0", 18); + token1 = new MockERC20("Token1", "TKN1", 18); + (token0, token1) = token0 < token1 ? (token0, token1) : (token1, token0); + + // init v4 nfpm & migrator + vault = new Vault(); + poolManager = new BinPoolManager(vault, 3000); + vault.registerApp(address(poolManager)); + binFungiblePositionManager = new BinFungiblePositionManager(vault, poolManager, address(weth)); + migrator = new BinMigrator(address(weth), address(binFungiblePositionManager)); + + poolKey = PoolKey({ + // WETH after migration will be native token + currency0: Currency.wrap(address(0)), + currency1: Currency.wrap(address(token0)), + hooks: IHooks(address(0)), + poolManager: poolManager, + fee: 0, + parameters: bytes32(0).setBinStep(1) + }); + + poolKeyWithoutNativeToken = poolKey; + poolKeyWithoutNativeToken.currency0 = Currency.wrap(address(token0)); + poolKeyWithoutNativeToken.currency1 = Currency.wrap(address(token1)); + + // make sure the contract has enough balance + // WETH: 100 ether + // Token: 100 ether + // ETH: 90 ether + deal(address(this), 1000 ether); + weth.deposit{value: 100 ether}(); + token0.mint(address(this), 100 ether); + token1.mint(address(this), 100 ether); + + v2Factory = IPancakeV2LikePairFactory(createContractThroughBytecode(_getBytecodePath())); + v2Pair = IPancakePair(v2Factory.createPair(address(weth), address(token0))); + v2PairWithoutNativeToken = IPancakePair(v2Factory.createPair(address(token0), address(token1))); + } + + function testMigrateFromV2IncludingInit() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 3. multicall, combine initialize and migrateFromV2 + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, ACTIVE_BIN_ID, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV2.selector, v2PoolParams, v4BinPoolParams, 0, 0); + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV2IncludingInit"))); + migrator.multicall(data); + snapEnd(); + + // necessary checks + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV2TokenMismatch() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + // v2 weth, token0 + // v4 ETH, token1 + PoolKey memory poolKeyMismatch = poolKey; + poolKeyMismatch.currency1 = Currency.wrap(address(token1)); + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: poolKeyMismatch, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 3. multicall, combine initialize and migrateFromV2 + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKeyMismatch, ACTIVE_BIN_ID, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV2.selector, v2PoolParams, v4BinPoolParams, 0, 0); + vm.expectRevert(); + migrator.multicall(data); + + { + // v2 weth, token0 + // v4 token0, token1 + poolKeyMismatch.currency0 = Currency.wrap(address(token0)); + poolKeyMismatch.currency1 = Currency.wrap(address(token1)); + v4BinPoolParams.poolKey = poolKeyMismatch; + data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKeyMismatch, ACTIVE_BIN_ID, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV2.selector, v2PoolParams, v4BinPoolParams, 0, 0); + vm.expectRevert(); + migrator.multicall(data); + } + } + + function testMigrateFromV2WithoutInit() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + migrator.initialize(poolKey, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 4. migrateFromV2 + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV2WithoutInit"))); + migrator.migrateFromV2(v2PoolParams, v4BinPoolParams, 0, 0); + snapEnd(); + + // necessary checks + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV2WithoutNativeToken() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2PairWithoutNativeToken); + uint256 lpTokenBefore = v2PairWithoutNativeToken.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2PairWithoutNativeToken.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + migrator.initialize(poolKeyWithoutNativeToken, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2PairWithoutNativeToken), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = _getAddParams( + poolKeyWithoutNativeToken, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this) + ); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 4. migrate from v2 to v4 + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV2WithoutNativeToken"))); + migrator.migrateFromV2(v2PoolParams, v4BinPoolParams, 0, 0); + snapEnd(); + + // necessary checks + // v2 pair should be burned already + assertEq(v2PairWithoutNativeToken.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + assertApproxEqAbs(token1.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV2AddExtraAmount() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + migrator.initialize(poolKey, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + IERC20(address(token0)).approve(address(migrator), 20 ether); + // 4. migrate from v2 to v4 + migrator.migrateFromV2{value: 20 ether}(v2PoolParams, v4BinPoolParams, 20 ether, 20 ether); + + // necessary checks + // consumed extra 20 ether from user + assertApproxEqAbs(balance0Before - address(this).balance, 20 ether, 0.000001 ether); + assertEq(balance1Before - token0.balanceOf(address(this)), 20 ether); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 90 ether); + + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 30 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 30 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV2AddExtraAmountThroughWETH() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + migrator.initialize(poolKey, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + weth.approve(address(migrator), 20 ether); + IERC20(address(token0)).approve(address(migrator), 20 ether); + // 4. migrate from v2 to v4, not sending ETH denotes pay by WETH + migrator.migrateFromV2(v2PoolParams, v4BinPoolParams, 20 ether, 20 ether); + + // necessary checks + // consumed extra 20 ether from user + // native token balance unchanged + assertApproxEqAbs(balance0Before - address(this).balance, 0 ether, 0.000001 ether); + assertEq(balance1Before - token0.balanceOf(address(this)), 20 ether); + // consumed 20 ether WETH + assertEq(weth.balanceOf(address(this)), 70 ether); + + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 30 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 30 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV2Refund() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair, 10 ether, 10 ether); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + migrator.initialize(poolKey, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // the order of token0 and token1 respect to the pair + // but may mismatch the order of v4 pool key when WETH is invovled + amount0Min: 9.99 ether, + amount1Min: 9.99 ether + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + int256[] memory deltaIds = new int256[](2); + deltaIds[0] = params.deltaIds[0]; + deltaIds[1] = params.deltaIds[1]; + + uint256[] memory distributionX = new uint256[](2); + distributionX[0] = params.distributionX[0]; + distributionX[1] = params.distributionX[1]; + + uint256[] memory distributionY = new uint256[](2); + distributionY[0] = params.distributionY[0]; + distributionY[1] = params.distributionY[1]; + + // delete the last distribution point so that the refund is triggered + // we expect to get 50% of tokenX back + // (0, 50%) (50%, 50%) (50%, 0) => (0, 50%) (50%, 50%) + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: deltaIds, + distributionX: distributionX, + distributionY: distributionY, + to: params.to, + deadline: params.deadline + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + // 4. migrate from v2 to v4, not sending ETH denotes pay by WETH + migrator.migrateFromV2(v2PoolParams, v4BinPoolParams, 0, 0); + + // necessary checks + // refund 5 ether in the form of native token + assertApproxEqAbs(address(this).balance - balance0Before, 5 ether, 0.000001 ether); + assertEq(balance1Before - token0.balanceOf(address(this)), 0 ether); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 90 ether); + + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 5 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV2RefundNonNativeToken() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2PairWithoutNativeToken, 10 ether, 10 ether); + uint256 lpTokenBefore = v2PairWithoutNativeToken.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2PairWithoutNativeToken.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + migrator.initialize(poolKeyWithoutNativeToken, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2PairWithoutNativeToken), + migrateAmount: lpTokenBefore, + // the order of token0 and token1 respect to the pair + // but may mismatch the order of v4 pool key when WETH is invovled + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = _getAddParams( + poolKeyWithoutNativeToken, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this) + ); + + int256[] memory deltaIds = new int256[](2); + deltaIds[0] = params.deltaIds[0]; + deltaIds[1] = params.deltaIds[1]; + + uint256[] memory distributionX = new uint256[](2); + distributionX[0] = params.distributionX[0]; + distributionX[1] = params.distributionX[1]; + + uint256[] memory distributionY = new uint256[](2); + distributionY[0] = params.distributionY[0]; + distributionY[1] = params.distributionY[1]; + + // delete the last distribution point so that the refund is triggered + // we expect to get 50% of tokenX back + // (0, 50%) (50%, 50%) (50%, 0) => (0, 50%) (50%, 50%) + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: deltaIds, + distributionX: distributionX, + distributionY: distributionY, + to: params.to, + deadline: params.deadline + }); + + uint256 balance0Before = token0.balanceOf(address(this)); + uint256 balance1Before = token1.balanceOf(address(this)); + + // 4. migrate from v2 to v4 + migrator.migrateFromV2(v2PoolParams, v4BinPoolParams, 0, 0); + + // necessary checks + + // refund 5 ether of token0 + assertApproxEqAbs(token0.balanceOf(address(this)) - balance0Before, 5 ether, 0.000001 ether); + assertEq(balance1Before - token1.balanceOf(address(this)), 0 ether); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 100 ether); + + // v2 pair should be burned already + assertEq(v2PairWithoutNativeToken.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(token0.balanceOf(address(vault)), 5 ether, 0.000001 ether); + assertApproxEqAbs(token1.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV2ThroughOffchainSign() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + // v2Pair.approve(address(migrator), lpTokenBefore); + (address userAddr, uint256 userPrivateKey) = makeAddrAndKey("user"); + + // 2.a transfer the lp token to the user + v2Pair.transfer(userAddr, lpTokenBefore); + + uint256 ddl = block.timestamp + 100; + // 2.b prepare the hash + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + userAddr, + address(migrator), + lpTokenBefore, + v2Pair.nonces(userAddr), + ddl + ) + ); + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", v2Pair.DOMAIN_SEPARATOR(), structHash)); + + // 2.c generate the signature + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 3. multicall, combine permit, initialize and migrateFromV2 + bytes[] memory data = new bytes[](3); + data[0] = abi.encodeWithSelector(migrator.selfPermit.selector, v2Pair, lpTokenBefore, ddl, v, r, s); + data[1] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, ACTIVE_BIN_ID, bytes("")); + data[2] = abi.encodeWithSelector(migrator.migrateFromV2.selector, v2PoolParams, v4BinPoolParams, 0, 0); + vm.prank(userAddr); + migrator.multicall(data); + + // necessary checks + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function _mintV2Liquidity(IPancakePair pair) public { + IERC20(pair.token0()).transfer(address(pair), 10 ether); + IERC20(pair.token1()).transfer(address(pair), 10 ether); + + pair.mint(address(this)); + } + + function _mintV2Liquidity(IPancakePair pair, uint256 amount0, uint256 amount1) public { + IERC20(pair.token0()).transfer(address(pair), amount0); + IERC20(pair.token1()).transfer(address(pair), amount1); + + pair.mint(address(this)); + } + + receive() external payable {} +} diff --git a/test/pool-bin/migrator/BinMigratorFromV3.sol b/test/pool-bin/migrator/BinMigratorFromV3.sol new file mode 100644 index 0000000..2f192d1 --- /dev/null +++ b/test/pool-bin/migrator/BinMigratorFromV3.sol @@ -0,0 +1,1163 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {OldVersionHelper} from "../../helpers/OldVersionHelper.sol"; +import {IPancakePair} from "../../../src/interfaces/external/IPancakePair.sol"; +import {WETH} from "solmate/tokens/WETH.sol"; +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {BinMigrator} from "../../../src/pool-bin/BinMigrator.sol"; +import {IBinMigrator, IBaseMigrator} from "../../../src/pool-bin/interfaces/IBinMigrator.sol"; +import {BinFungiblePositionManager} from "../../../src/pool-bin/BinFungiblePositionManager.sol"; +import {Vault} from "pancake-v4-core/src/Vault.sol"; +import {BinPoolManager} from "pancake-v4-core/src/pool-bin/BinPoolManager.sol"; +import {PoolKey} from "pancake-v4-core/src/types/PoolKey.sol"; +import {BinPoolParametersHelper} from "pancake-v4-core/src/pool-bin/libraries/BinPoolParametersHelper.sol"; +import {Currency} from "pancake-v4-core/src/types/Currency.sol"; +import {IPoolManager} from "pancake-v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "pancake-v4-core/src/interfaces/IHooks.sol"; +import {PoolId, PoolIdLibrary} from "pancake-v4-core/src/types/PoolId.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {IV3NonfungiblePositionManager} from "../../../src/interfaces/external/IV3NonfungiblePositionManager.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {LiquidityParamsHelper, IBinFungiblePositionManager} from "../helpers/LiquidityParamsHelper.sol"; +import {BinTokenLibrary} from "../../../src/pool-bin/libraries/BinTokenLibrary.sol"; + +interface IPancakeV3LikePairFactory { + function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); +} + +abstract contract BinMigratorFromV3 is OldVersionHelper, LiquidityParamsHelper, GasSnapshot { + using BinPoolParametersHelper for bytes32; + using PoolIdLibrary for PoolKey; + using BinTokenLibrary for PoolId; + + uint160 public constant INIT_SQRT_PRICE = 79228162514264337593543950336; + // 1 tokenX = 1 tokenY + uint24 public constant ACTIVE_BIN_ID = 2 ** 23; + + WETH weth; + MockERC20 token0; + MockERC20 token1; + + Vault vault; + BinPoolManager poolManager; + BinFungiblePositionManager binFungiblePositionManager; + IBinMigrator migrator; + PoolKey poolKey; + PoolKey poolKeyWithoutNativeToken; + + IPancakeV3LikePairFactory v3Factory; + IV3NonfungiblePositionManager v3Nfpm; + + function _getDeployerBytecodePath() internal pure virtual returns (string memory); + function _getFactoryBytecodePath() internal pure virtual returns (string memory); + function _getNfpmBytecodePath() internal pure virtual returns (string memory); + + function _getContractName() internal pure virtual returns (string memory); + + function setUp() public { + weth = new WETH(); + token0 = new MockERC20("Token0", "TKN0", 18); + token1 = new MockERC20("Token1", "TKN1", 18); + (token0, token1) = token0 < token1 ? (token0, token1) : (token1, token0); + + // init v4 nfpm & migrator + vault = new Vault(); + poolManager = new BinPoolManager(vault, 3000); + vault.registerApp(address(poolManager)); + binFungiblePositionManager = new BinFungiblePositionManager(vault, poolManager, address(weth)); + migrator = new BinMigrator(address(weth), address(binFungiblePositionManager)); + + poolKey = PoolKey({ + // WETH after migration will be native token + currency0: Currency.wrap(address(0)), + currency1: Currency.wrap(address(token0)), + hooks: IHooks(address(0)), + poolManager: poolManager, + fee: 0, + parameters: bytes32(0).setBinStep(1) + }); + + poolKeyWithoutNativeToken = poolKey; + poolKeyWithoutNativeToken.currency0 = Currency.wrap(address(token0)); + poolKeyWithoutNativeToken.currency1 = Currency.wrap(address(token1)); + + // make sure the contract has enough balance + // WETH: 100 ether + // Token: 100 ether + // ETH: 90 ether + deal(address(this), 1000 ether); + weth.deposit{value: 100 ether}(); + token0.mint(address(this), 100 ether); + token1.mint(address(this), 100 ether); + + // pcs v3 + if (bytes(_getDeployerBytecodePath()).length != 0) { + address deployer = createContractThroughBytecode(_getDeployerBytecodePath()); + v3Factory = IPancakeV3LikePairFactory( + createContractThroughBytecode(_getFactoryBytecodePath(), toBytes32(address(deployer))) + ); + (bool success,) = deployer.call(abi.encodeWithSignature("setFactoryAddress(address)", address(v3Factory))); + require(success, "setFactoryAddress failed"); + v3Nfpm = IV3NonfungiblePositionManager( + createContractThroughBytecode( + _getNfpmBytecodePath(), + toBytes32(deployer), + toBytes32(address(v3Factory)), + toBytes32(address(weth)), + 0 + ) + ); + } else { + v3Factory = IPancakeV3LikePairFactory(createContractThroughBytecode(_getFactoryBytecodePath())); + + v3Nfpm = IV3NonfungiblePositionManager( + createContractThroughBytecode( + _getNfpmBytecodePath(), toBytes32(address(v3Factory)), toBytes32(address(weth)), 0 + ) + ); + } + + // make sure v3Nfpm has allowance + weth.approve(address(v3Nfpm), type(uint256).max); + token0.approve(address(v3Nfpm), type(uint256).max); + token1.approve(address(v3Nfpm), type(uint256).max); + } + + function testMigrateFromV3IncludingInit() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 3. multicall, combine initialize and migrateFromV3 + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, ACTIVE_BIN_ID, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4BinPoolParams, 0, 0); + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV3IncludingInit"))); + migrator.multicall(data); + snapEnd(); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pooA + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV3TokenMismatch() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + // v3 weth, token0 + // v4 ETH, token1 + PoolKey memory poolKeyMismatch = poolKey; + poolKeyMismatch.currency1 = Currency.wrap(address(token1)); + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: poolKeyMismatch, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 3. multicall, combine initialize and migrateFromV3 + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKeyMismatch, ACTIVE_BIN_ID, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4BinPoolParams, 0, 0); + vm.expectRevert(); + migrator.multicall(data); + + { + // v3 weth, token0 + // v4 token0, token1 + poolKeyMismatch.currency0 = Currency.wrap(address(token0)); + poolKeyMismatch.currency1 = Currency.wrap(address(token1)); + v4BinPoolParams.poolKey = poolKeyMismatch; + data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKeyMismatch, ACTIVE_BIN_ID, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4BinPoolParams, 0, 0); + vm.expectRevert(); + migrator.multicall(data); + } + } + + function testMigrateFromV3WithoutInit() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. initialize the pool + migrator.initialize(poolKey, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 4. migrateFromV3 directly given pool has been initialized + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV3WithoutInit"))); + migrator.migrateFromV3(v3PoolParams, v4BinPoolParams, 0, 0); + snapEnd(); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV3WithoutNativeToken() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(token0), address(token1)); + + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. initialize the pool + migrator.initialize(poolKeyWithoutNativeToken, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = _getAddParams( + poolKeyWithoutNativeToken, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this) + ); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 4. migrate from v3 to v4 + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV3WithoutNativeToken"))); + migrator.migrateFromV3(v3PoolParams, v4BinPoolParams, 0, 0); + snapEnd(); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + assertApproxEqAbs(token1.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV3AddExtraAmount() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + migrator.initialize(poolKey, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + IERC20(address(token0)).approve(address(migrator), 20 ether); + // 4. migrate from v3 to v4 + migrator.migrateFromV3{value: 20 ether}(v3PoolParams, v4BinPoolParams, 20 ether, 20 ether); + + // necessary checks + // consumed extra 20 ether from user + assertApproxEqAbs(balance0Before - address(this).balance, 20 ether, 0.000001 ether); + assertApproxEqAbs(balance1Before - token0.balanceOf(address(this)), 20 ether, 0.000001 ether); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 90 ether); + + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 30 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 30 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV3AddExtraAmountThroughWETH() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + migrator.initialize(poolKey, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + weth.approve(address(migrator), 20 ether); + IERC20(address(token0)).approve(address(migrator), 20 ether); + // 4. migrate from v3 to v4, not sending ETH denotes pay by WETH + migrator.migrateFromV3(v3PoolParams, v4BinPoolParams, 20 ether, 20 ether); + + // necessary checks + // consumed extra 20 ether from user + // native token balance unchanged + assertApproxEqAbs(address(this).balance - balance0Before, 0 ether, 0.000001 ether); + assertApproxEqAbs(balance1Before - token0.balanceOf(address(this)), 20 ether, 0.00001 ether); + // consumed 20 ether WETH + assertEq(weth.balanceOf(address(this)), 70 ether); + + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 30 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 30 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV3Refund() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + migrator.initialize(poolKey, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 0, + amount1Min: 0, + collectFee: false, + deadline: block.timestamp + 100 + }); + + // adding half of the liquidity to the pool + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + int256[] memory deltaIds = new int256[](2); + deltaIds[0] = params.deltaIds[0]; + deltaIds[1] = params.deltaIds[1]; + + uint256[] memory distributionX = new uint256[](2); + distributionX[0] = params.distributionX[0]; + distributionX[1] = params.distributionX[1]; + + uint256[] memory distributionY = new uint256[](2); + distributionY[0] = params.distributionY[0]; + distributionY[1] = params.distributionY[1]; + + // delete the last distribution point so that the refund is triggered + // we expect to get 50% of tokenX back + // (0, 50%) (50%, 50%) (50%, 0) => (0, 50%) (50%, 50%) + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: deltaIds, + distributionX: distributionX, + distributionY: distributionY, + to: params.to, + deadline: params.deadline + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + // 4. migrate from v3 to v4, not sending ETH denotes pay by WETH + migrator.migrateFromV3(v3PoolParams, v4BinPoolParams, 0, 0); + + // necessary checks + // refund 5 ether in the form of native token + assertApproxEqAbs(address(this).balance - balance0Before, 5.0 ether, 0.1 ether); + assertApproxEqAbs(token0.balanceOf(address(this)) - balance1Before, 0 ether, 1); + // WETH balance unchanged + assertApproxEqAbs(weth.balanceOf(address(this)), 90 ether, 0.1 ether); + + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(address(vault).balance, 5 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV3RefundNonNativeToken() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(token0), address(token1)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + migrator.initialize(poolKeyWithoutNativeToken, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 0, + amount1Min: 0, + collectFee: false, + deadline: block.timestamp + 100 + }); + + // adding half of the liquidity to the pool + IBinFungiblePositionManager.AddLiquidityParams memory params = _getAddParams( + poolKeyWithoutNativeToken, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this) + ); + + int256[] memory deltaIds = new int256[](2); + deltaIds[0] = params.deltaIds[0]; + deltaIds[1] = params.deltaIds[1]; + + uint256[] memory distributionX = new uint256[](2); + distributionX[0] = params.distributionX[0]; + distributionX[1] = params.distributionX[1]; + + uint256[] memory distributionY = new uint256[](2); + distributionY[0] = params.distributionY[0]; + distributionY[1] = params.distributionY[1]; + + // delete the last distribution point so that the refund is triggered + // we expect to get 50% of tokenX back + // (0, 50%) (50%, 50%) (50%, 0) => (0, 50%) (50%, 50%) + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: deltaIds, + distributionX: distributionX, + distributionY: distributionY, + to: params.to, + deadline: params.deadline + }); + + uint256 balance0Before = token0.balanceOf(address(this)); + uint256 balance1Before = token1.balanceOf(address(this)); + + // 4. migrate from v3 to v4 + migrator.migrateFromV3(v3PoolParams, v4BinPoolParams, 0, 0); + + // necessary checks + + // refund 5 ether of token0 + assertApproxEqAbs(token0.balanceOf(address(this)) - balance0Before, 5 ether, 0.1 ether); + assertApproxEqAbs(token1.balanceOf(address(this)) - balance1Before, 0 ether, 1); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 100 ether); + + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertApproxEqAbs(token0.balanceOf(address(vault)), 5 ether, 0.000001 ether); + assertApproxEqAbs(token1.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKeyWithoutNativeToken.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV3FromNonOwner() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + migrator.initialize(poolKey, ACTIVE_BIN_ID, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + // half of the liquidity + liquidity: liquidityFromV3Before / 2, + amount0Min: 9.9 ether / 2, + amount1Min: 9.9 ether / 2, + collectFee: false, + deadline: block.timestamp + 100 + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + int256[] memory deltaIds = new int256[](2); + deltaIds[0] = params.deltaIds[0]; + deltaIds[1] = params.deltaIds[1]; + + uint256[] memory distributionX = new uint256[](2); + distributionX[0] = params.distributionX[0]; + distributionX[1] = params.distributionX[1]; + + uint256[] memory distributionY = new uint256[](2); + distributionY[0] = params.distributionY[0]; + distributionY[1] = params.distributionY[1]; + + // delete the last distribution point so that the refund is triggered + // we expect to get 50% of tokenX back + // (0, 50%) (50%, 50%) (50%, 0) => (0, 50%) (50%, 50%) + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: deltaIds, + distributionX: distributionX, + distributionY: distributionY, + to: params.to, + deadline: params.deadline + }); + + // 4. migrate half + migrator.migrateFromV3(v3PoolParams, v4BinPoolParams, 0, 0); + + // make sure there are still liquidity left in v3 position token + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, liquidityFromV3Before - liquidityFromV3Before / 2); + + // 5. make sure non-owner can't migrate the rest + vm.expectRevert(IBaseMigrator.NOT_TOKEN_OWNER.selector); + vm.prank(makeAddr("someone")); + migrator.migrateFromV3(v3PoolParams, v4BinPoolParams, 0, 0); + } + + function testMigrateFromV3ThroughOffchainSign() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (uint96 nonce,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token through offchain sign + // v3Nfpm.approve(address(migrator), 1); + (address userAddr, uint256 userPrivateKey) = makeAddrAndKey("user"); + + // 2.a transfer the lp token to the user + v3Nfpm.transferFrom(address(this), userAddr, 1); + + uint256 ddl = block.timestamp + 100; + // 2.b prepare the hash + bytes32 structHash = keccak256(abi.encode(v3Nfpm.PERMIT_TYPEHASH(), address(migrator), 1, nonce, ddl)); + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", v3Nfpm.DOMAIN_SEPARATOR(), structHash)); + + // 2.c generate the signature + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // 3. multicall, combine selfPermitERC721, initialize and migrateFromV3 + bytes[] memory data = new bytes[](3); + data[0] = abi.encodeWithSelector(migrator.selfPermitERC721.selector, v3Nfpm, 1, ddl, v, r, s); + data[1] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, ACTIVE_BIN_ID, bytes("")); + data[2] = abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4BinPoolParams, 0, 0); + vm.prank(userAddr); + migrator.multicall(data); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pooA + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function testMigrateFromV3ThroughOffchainSignPayWithETH() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (uint96 nonce,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token through offchain sign + // v3Nfpm.approve(address(migrator), 1); + (address userAddr, uint256 userPrivateKey) = makeAddrAndKey("user"); + + // 2.a transfer the lp token to the user + v3Nfpm.transferFrom(address(this), userAddr, 1); + + uint256 ddl = block.timestamp + 100; + // 2.b prepare the hash + bytes32 structHash = keccak256(abi.encode(v3Nfpm.PERMIT_TYPEHASH(), address(migrator), 1, nonce, ddl)); + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", v3Nfpm.DOMAIN_SEPARATOR(), structHash)); + + // 2.c generate the signature + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + IBinFungiblePositionManager.AddLiquidityParams memory params = + _getAddParams(poolKey, getBinIds(ACTIVE_BIN_ID, 3), 10 ether, 10 ether, ACTIVE_BIN_ID, address(this)); + + IBinMigrator.V4BinPoolParams memory v4BinPoolParams = IBinMigrator.V4BinPoolParams({ + poolKey: params.poolKey, + amount0Min: params.amount0Min, + amount1Min: params.amount1Min, + activeIdDesired: params.activeIdDesired, + idSlippage: params.idSlippage, + deltaIds: params.deltaIds, + distributionX: params.distributionX, + distributionY: params.distributionY, + to: params.to, + deadline: params.deadline + }); + + // make the guy rich + token0.transfer(userAddr, 10 ether); + deal(userAddr, 10 ether); + + vm.prank(userAddr); + token0.approve(address(migrator), 10 ether); + + // 3. multicall, combine selfPermitERC721, initialize and migrateFromV3 + bytes[] memory data = new bytes[](3); + data[0] = abi.encodeWithSelector(migrator.selfPermitERC721.selector, v3Nfpm, 1, ddl, v, r, s); + data[1] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, ACTIVE_BIN_ID, bytes("")); + data[2] = + abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4BinPoolParams, 10 ether, 10 ether); + vm.prank(userAddr); + migrator.multicall{value: 10 ether}(data); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pooA + assertApproxEqAbs(address(vault).balance, 20 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 20 ether, 0.000001 ether); + + uint256 positionId0 = poolKey.toId().toTokenId(ACTIVE_BIN_ID - 1); + uint256 positionId1 = poolKey.toId().toTokenId(ACTIVE_BIN_ID); + uint256 positionId2 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 1); + uint256 positionId3 = poolKey.toId().toTokenId(ACTIVE_BIN_ID + 2); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId0), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId1), 0); + assertGt(binFungiblePositionManager.balanceOf(address(this), positionId2), 0); + assertEq(binFungiblePositionManager.balanceOf(address(this), positionId3), 0); + + (PoolId poolId, Currency currency0, Currency currency1, uint24 fee, uint24 binId) = + binFungiblePositionManager.positions(positionId0); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID - 1); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId1); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID); + + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId2); + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(binId, ACTIVE_BIN_ID + 1); + + vm.expectRevert(IBinFungiblePositionManager.InvalidTokenID.selector); + (poolId, currency0, currency1, fee, binId) = binFungiblePositionManager.positions(positionId3); + } + + function _mintV3Liquidity(address _token0, address _token1) internal { + (_token0, _token1) = _token0 < _token1 ? (_token0, _token1) : (_token1, _token0); + v3Nfpm.createAndInitializePoolIfNecessary(_token0, _token1, 500, INIT_SQRT_PRICE); + IV3NonfungiblePositionManager.MintParams memory mintParams = IV3NonfungiblePositionManager.MintParams({ + token0: _token0, + token1: _token1, + fee: 500, + tickLower: -100, + tickUpper: 100, + amount0Desired: 10 ether, + amount1Desired: 10 ether, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + v3Nfpm.mint(mintParams); + } + + receive() external payable {} +} diff --git a/test/pool-cl/migrator/CLMigratorFromPancakeswapV2.t.sol b/test/pool-cl/migrator/CLMigratorFromPancakeswapV2.t.sol new file mode 100644 index 0000000..8c5dd24 --- /dev/null +++ b/test/pool-cl/migrator/CLMigratorFromPancakeswapV2.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {CLMigratorFromV2} from "./CLMigratorFromV2.sol"; + +contract CLMigratorFromPancakeswapV2Test is CLMigratorFromV2 { + function _getBytecodePath() internal pure override returns (string memory) { + // Create a Pancakeswap V2 pair + // relative to the root of the project + // https://etherscan.io/address/0x1097053Fd2ea711dad45caCcc45EfF7548fCB362#code + return "./test/bin/pcsV2Factory.bytecode"; + } + + function _getContractName() internal pure override returns (string memory) { + return "CLMigratorFromPancakeswapV2Test"; + } +} diff --git a/test/pool-cl/migrator/CLMigratorFromPancakeswapV3.t.sol b/test/pool-cl/migrator/CLMigratorFromPancakeswapV3.t.sol new file mode 100644 index 0000000..309caae --- /dev/null +++ b/test/pool-cl/migrator/CLMigratorFromPancakeswapV3.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {CLMigratorFromV3} from "./CLMigratorFromV3.sol"; + +contract CLMigratorFromPancakeswapV3Test is CLMigratorFromV3 { + function _getDeployerBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0x41ff9AA7e16B8B1a8a8dc4f0eFacd93D02d071c9#code + return "./test/bin/pcsV3Deployer.bytecode"; + } + + function _getFactoryBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865#code + return "./test/bin/pcsV3Factory.bytecode"; + } + + function _getNfpmBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0x46A15B0b27311cedF172AB29E4f4766fbE7F4364#code + return "./test/bin/pcsV3Nfpm.bytecode"; + } + + function _getContractName() internal pure override returns (string memory) { + return "CLMigratorFromPancakeswapV3Test"; + } +} diff --git a/test/pool-cl/migrator/CLMigratorFromUniswapV2.t.sol b/test/pool-cl/migrator/CLMigratorFromUniswapV2.t.sol new file mode 100644 index 0000000..41f9a3b --- /dev/null +++ b/test/pool-cl/migrator/CLMigratorFromUniswapV2.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {CLMigratorFromV2} from "./CLMigratorFromV2.sol"; + +contract CLMigratorFromUniswapV2Test is CLMigratorFromV2 { + function _getBytecodePath() internal pure override returns (string memory) { + // Create a Uniswap V2 pair + // relative to the root of the project + // https://etherscan.io/address/0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f#code + return "./test/bin/uniV2Factory.bytecode"; + } + + function _getContractName() internal pure override returns (string memory) { + return "CLMigratorFromUniswapV2Test"; + } +} diff --git a/test/pool-cl/migrator/CLMigratorFromUniswapV3.t.sol b/test/pool-cl/migrator/CLMigratorFromUniswapV3.t.sol new file mode 100644 index 0000000..638f1b0 --- /dev/null +++ b/test/pool-cl/migrator/CLMigratorFromUniswapV3.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {CLMigratorFromV3} from "./CLMigratorFromV3.sol"; + +contract CLMigratorFromUniswapV3Test is CLMigratorFromV3 { + function _getDeployerBytecodePath() internal pure override returns (string memory) { + return ""; + } + + function _getFactoryBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0x1F98431c8aD98523631AE4a59f267346ea31F984#code + return "./test/bin/uniV3Factory.bytecode"; + } + + function _getNfpmBytecodePath() internal pure override returns (string memory) { + // https://etherscan.io/address/0xC36442b4a4522E871399CD717aBDD847Ab11FE88#code + return "./test/bin/uniV3Nfpm.bytecode"; + } + + function _getContractName() internal pure override returns (string memory) { + return "CLMigratorFromUniswapV3Test"; + } +} diff --git a/test/pool-cl/migrator/CLMigratorFromV2.sol b/test/pool-cl/migrator/CLMigratorFromV2.sol new file mode 100644 index 0000000..b8e6205 --- /dev/null +++ b/test/pool-cl/migrator/CLMigratorFromV2.sol @@ -0,0 +1,836 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {OldVersionHelper} from "../../helpers/OldVersionHelper.sol"; +import {IPancakePair} from "../../../src/interfaces/external/IPancakePair.sol"; +import {WETH} from "solmate/tokens/WETH.sol"; +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {CLMigrator} from "../../../src/pool-cl/CLMigrator.sol"; +import {ICLMigrator, IBaseMigrator} from "../../../src/pool-cl/interfaces/ICLMigrator.sol"; +import {NonfungiblePositionManager} from "../../../src/pool-cl/NonfungiblePositionManager.sol"; +import {Vault} from "pancake-v4-core/src/Vault.sol"; +import {CLPoolManager} from "pancake-v4-core/src/pool-cl/CLPoolManager.sol"; +import {PoolKey} from "pancake-v4-core/src/types/PoolKey.sol"; +import {CLPoolParametersHelper} from "pancake-v4-core/src/pool-cl/libraries/CLPoolParametersHelper.sol"; +import {Currency} from "pancake-v4-core/src/types/Currency.sol"; +import {IPoolManager} from "pancake-v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "pancake-v4-core/src/interfaces/IHooks.sol"; +import {PoolId, PoolIdLibrary} from "pancake-v4-core/src/types/PoolId.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; + +interface IPancakeV2LikePairFactory { + function getPair(address tokenA, address tokenB) external view returns (address pair); + function createPair(address tokenA, address tokenB) external returns (address pair); +} + +abstract contract CLMigratorFromV2 is OldVersionHelper, GasSnapshot { + using CLPoolParametersHelper for bytes32; + using PoolIdLibrary for PoolKey; + + WETH weth; + MockERC20 token0; + MockERC20 token1; + + Vault vault; + CLPoolManager poolManager; + NonfungiblePositionManager nonfungiblePoolManager; + ICLMigrator migrator; + PoolKey poolKey; + PoolKey poolKeyWithoutNativeToken; + + IPancakeV2LikePairFactory v2Factory; + IPancakePair v2Pair; + IPancakePair v2PairWithoutNativeToken; + + function _getBytecodePath() internal pure virtual returns (string memory); + + function _getContractName() internal pure virtual returns (string memory); + + function setUp() public { + weth = new WETH(); + token0 = new MockERC20("Token0", "TKN0", 18); + token1 = new MockERC20("Token1", "TKN1", 18); + (token0, token1) = token0 < token1 ? (token0, token1) : (token1, token0); + + // init v4 nfpm & migrator + vault = new Vault(); + poolManager = new CLPoolManager(vault, 3000); + vault.registerApp(address(poolManager)); + nonfungiblePoolManager = new NonfungiblePositionManager(vault, poolManager, address(0), address(weth)); + migrator = new CLMigrator(address(weth), address(nonfungiblePoolManager)); + + poolKey = PoolKey({ + // WETH after migration will be native token + currency0: Currency.wrap(address(0)), + currency1: Currency.wrap(address(token0)), + hooks: IHooks(address(0)), + poolManager: poolManager, + fee: 0, + parameters: bytes32(0).setTickSpacing(10) + }); + + poolKeyWithoutNativeToken = poolKey; + poolKeyWithoutNativeToken.currency0 = Currency.wrap(address(token0)); + poolKeyWithoutNativeToken.currency1 = Currency.wrap(address(token1)); + + // make sure the contract has enough balance + // WETH: 100 ether + // Token: 100 ether + // ETH: 90 ether + deal(address(this), 1000 ether); + weth.deposit{value: 100 ether}(); + token0.mint(address(this), 100 ether); + token1.mint(address(this), 100 ether); + + v2Factory = IPancakeV2LikePairFactory(createContractThroughBytecode(_getBytecodePath())); + v2Pair = IPancakePair(v2Factory.createPair(address(weth), address(token0))); + v2PairWithoutNativeToken = IPancakePair(v2Factory.createPair(address(token0), address(token1))); + } + + function testMigrateFromV2IncludingInit() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 3. multicall, combine initialize and migrateFromV2 + uint160 initSqrtPrice = 79228162514264337593543950336; + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, initSqrtPrice, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV2.selector, v2PoolParams, v4MintParams, 0, 0); + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV2IncludingInit"))); + migrator.multicall(data); + snapEnd(); + + // necessary checks + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + assertEq(liquidity, 2005104164790027832367); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + } + + function testMigrateFromV2TokenMismatch() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + // v2 weth, token0 + // v4 ETH, token1 + PoolKey memory poolKeyMismatch = poolKey; + poolKeyMismatch.currency1 = Currency.wrap(address(token1)); + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKeyMismatch, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 3. multicall, combine initialize and migrateFromV2 + uint160 initSqrtPrice = 79228162514264337593543950336; + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKeyMismatch, initSqrtPrice, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV2.selector, v2PoolParams, v4MintParams, 0, 0); + vm.expectRevert(); + migrator.multicall(data); + + { + // v2 weth, token0 + // v4 token0, token1 + poolKeyMismatch.currency0 = Currency.wrap(address(token0)); + poolKeyMismatch.currency1 = Currency.wrap(address(token1)); + v4MintParams.poolKey = poolKeyMismatch; + data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKeyMismatch, initSqrtPrice, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV2.selector, v2PoolParams, v4MintParams, 0, 0); + vm.expectRevert(); + migrator.multicall(data); + } + } + + function testMigrateFromV2WithoutInit() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + uint160 initSqrtPrice = 79228162514264337593543950336; + migrator.initialize(poolKey, initSqrtPrice, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 4. migrate from v2 to v4 + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV2WithoutInit"))); + migrator.migrateFromV2(v2PoolParams, v4MintParams, 0, 0); + snapEnd(); + + // necessary checks + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + assertEq(liquidity, 2005104164790027832367); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + } + + function testMigrateFromV2WithoutNativeToken() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2PairWithoutNativeToken); + uint256 lpTokenBefore = v2PairWithoutNativeToken.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2PairWithoutNativeToken.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + uint160 initSqrtPrice = 79228162514264337593543950336; + migrator.initialize(poolKeyWithoutNativeToken, initSqrtPrice, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2PairWithoutNativeToken), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKeyWithoutNativeToken, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 4. migrate from v2 to v4 + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV2WithoutNativeToken"))); + migrator.migrateFromV2(v2PoolParams, v4MintParams, 0, 0); + snapEnd(); + + // necessary checks + // v2 pair should be burned already + assertEq(v2PairWithoutNativeToken.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + assertEq(liquidity, 2005104164790027832367); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + assertApproxEqAbs(token1.balanceOf(address(vault)), 10 ether, 0.000001 ether); + } + + function testMigrateFromV2AddExtraAmount() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + uint160 initSqrtPrice = 79228162514264337593543950336; + migrator.initialize(poolKey, initSqrtPrice, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + IERC20(address(token0)).approve(address(migrator), 20 ether); + // 4. migrate from v2 to v4 + migrator.migrateFromV2{value: 20 ether}(v2PoolParams, v4MintParams, 20 ether, 20 ether); + + // necessary checks + // consumed extra 20 ether from user + assertApproxEqAbs(balance0Before - address(this).balance, 20 ether, 0.000001 ether); + assertEq(balance1Before - token0.balanceOf(address(this)), 20 ether); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 90 ether); + + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + // liquidity is 3 times of the original + assertApproxEqAbs(liquidity, 2005104164790027832367 * 3, 0.000001 ether); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 30 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 30 ether, 0.000001 ether); + } + + function testMigrateFromV2AddExtraAmountThroughWETH() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + uint160 initSqrtPrice = 79228162514264337593543950336; + migrator.initialize(poolKey, initSqrtPrice, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + weth.approve(address(migrator), 20 ether); + IERC20(address(token0)).approve(address(migrator), 20 ether); + // 4. migrate from v2 to v4, not sending ETH denotes pay by WETH + migrator.migrateFromV2(v2PoolParams, v4MintParams, 20 ether, 20 ether); + + // necessary checks + // consumed extra 20 ether from user + // native token balance unchanged + assertApproxEqAbs(balance0Before - address(this).balance, 0 ether, 0.000001 ether); + assertEq(balance1Before - token0.balanceOf(address(this)), 20 ether); + // consumed 20 ether WETH + assertEq(weth.balanceOf(address(this)), 70 ether); + + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + // liquidity is 3 times of the original + assertApproxEqAbs(liquidity, 2005104164790027832367 * 3, 0.000001 ether); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 30 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 30 ether, 0.000001 ether); + } + + function testMigrateFromV2Refund() public { + // 1. mint some liquidity to the v2 pair + // 10 ether WETH, 5 ether token0 + // addr of weth > addr of token0, hence the order has to be reversed + bool isWETHFirst = address(weth) < address(token0); + if (isWETHFirst) { + _mintV2Liquidity(v2Pair, 10 ether, 5 ether); + } else { + _mintV2Liquidity(v2Pair, 5 ether, 10 ether); + } + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2Pair.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + uint160 initSqrtPrice = 79228162514264337593543950336; + migrator.initialize(poolKey, initSqrtPrice, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // the order of token0 and token1 respect to the pair + // but may mismatch the order of v4 pool key when WETH is invovled + amount0Min: isWETHFirst ? 9.999 ether : 4.999 ether, + amount1Min: isWETHFirst ? 4.999 ether : 9.999 ether + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + // 4. migrate from v2 to v4, not sending ETH denotes pay by WETH + migrator.migrateFromV2(v2PoolParams, v4MintParams, 0, 0); + + // necessary checks + // refund 5 ether in the form of native token + assertApproxEqAbs(address(this).balance - balance0Before, 5 ether, 0.000001 ether); + assertEq(balance1Before - token0.balanceOf(address(this)), 0 ether); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 90 ether); + + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + // liquidity is half of the original + assertApproxEqAbs(liquidity * 2, 2005104164790027832367, 0.000001 ether); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 5 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 5 ether, 0.000001 ether); + } + + function testMigrateFromV2RefundNonNativeToken() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2PairWithoutNativeToken, 10 ether, 5 ether); + uint256 lpTokenBefore = v2PairWithoutNativeToken.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. make sure migrator can transfer user's v2 lp token + v2PairWithoutNativeToken.approve(address(migrator), lpTokenBefore); + + // 3. initialize the pool + uint160 initSqrtPrice = 79228162514264337593543950336; + migrator.initialize(poolKeyWithoutNativeToken, initSqrtPrice, bytes("")); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2PairWithoutNativeToken), + migrateAmount: lpTokenBefore, + // the order of token0 and token1 respect to the pair + // but may mismatch the order of v4 pool key when WETH is invovled + amount0Min: 9.999 ether, + amount1Min: 4.999 ether + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKeyWithoutNativeToken, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + uint256 balance0Before = token0.balanceOf(address(this)); + uint256 balance1Before = token1.balanceOf(address(this)); + + // 4. migrate from v2 to v4 + migrator.migrateFromV2(v2PoolParams, v4MintParams, 0, 0); + + // necessary checks + + // refund 5 ether of token0 + assertApproxEqAbs(token0.balanceOf(address(this)) - balance0Before, 5 ether, 0.000001 ether); + assertEq(balance1Before - token1.balanceOf(address(this)), 0 ether); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 100 ether); + + // v2 pair should be burned already + assertEq(v2PairWithoutNativeToken.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + // liquidity is half of the original + assertApproxEqAbs(liquidity * 2, 2005104164790027832367, 0.000001 ether); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(token0.balanceOf(address(vault)), 5 ether, 0.000001 ether); + assertApproxEqAbs(token1.balanceOf(address(vault)), 5 ether, 0.000001 ether); + } + + function testMigrateFromV2ThroughOffchainSign() public { + // 1. mint some liquidity to the v2 pair + _mintV2Liquidity(v2Pair); + uint256 lpTokenBefore = v2Pair.balanceOf(address(this)); + assertGt(lpTokenBefore, 0); + + // 2. instead of approve, we generate a offchain signature here + // v2Pair.approve(address(migrator), lpTokenBefore); + (address userAddr, uint256 userPrivateKey) = makeAddrAndKey("user"); + + // 2.a transfer the lp token to the user + v2Pair.transfer(userAddr, lpTokenBefore); + + uint256 ddl = block.timestamp + 100; + + // 2.b prepare the hash + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + userAddr, + address(migrator), + lpTokenBefore, + v2Pair.nonces(userAddr), + ddl + ) + ); + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", v2Pair.DOMAIN_SEPARATOR(), structHash)); + + // 2.c generate the signature + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash); + + IBaseMigrator.V2PoolParams memory v2PoolParams = IBaseMigrator.V2PoolParams({ + pair: address(v2Pair), + migrateAmount: lpTokenBefore, + // minor precision loss is acceptable + amount0Min: 9.999 ether, + amount1Min: 9.999 ether + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: ddl + }); + + // 3. multicall, combine permit, initialize and migrateFromV2 + uint160 initSqrtPrice = 79228162514264337593543950336; + bytes[] memory data = new bytes[](3); + data[0] = abi.encodeWithSelector(migrator.selfPermit.selector, v2Pair, lpTokenBefore, ddl, v, r, s); + data[1] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, initSqrtPrice, bytes("")); + data[2] = abi.encodeWithSelector(migrator.migrateFromV2.selector, v2PoolParams, v4MintParams, 0, 0); + vm.prank(userAddr); + migrator.multicall(data); + + // necessary checks + // v2 pair should be burned already + assertEq(v2Pair.balanceOf(address(this)), 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + assertEq(liquidity, 2005104164790027832367); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + } + + function _mintV2Liquidity(IPancakePair pair) public { + IERC20(pair.token0()).transfer(address(pair), 10 ether); + IERC20(pair.token1()).transfer(address(pair), 10 ether); + + pair.mint(address(this)); + } + + function _mintV2Liquidity(IPancakePair pair, uint256 amount0, uint256 amount1) public { + IERC20(pair.token0()).transfer(address(pair), amount0); + IERC20(pair.token1()).transfer(address(pair), amount1); + + pair.mint(address(this)); + } + + receive() external payable {} +} diff --git a/test/pool-cl/migrator/CLMigratorFromV3.sol b/test/pool-cl/migrator/CLMigratorFromV3.sol new file mode 100644 index 0000000..9961306 --- /dev/null +++ b/test/pool-cl/migrator/CLMigratorFromV3.sol @@ -0,0 +1,1062 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {OldVersionHelper} from "../../helpers/OldVersionHelper.sol"; +import {IPancakePair} from "../../../src/interfaces/external/IPancakePair.sol"; +import {WETH} from "solmate/tokens/WETH.sol"; +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {CLMigrator} from "../../../src/pool-cl/CLMigrator.sol"; +import {ICLMigrator, IBaseMigrator} from "../../../src/pool-cl/interfaces/ICLMigrator.sol"; +import {NonfungiblePositionManager} from "../../../src/pool-cl/NonfungiblePositionManager.sol"; +import {Vault} from "pancake-v4-core/src/Vault.sol"; +import {CLPoolManager} from "pancake-v4-core/src/pool-cl/CLPoolManager.sol"; +import {PoolKey} from "pancake-v4-core/src/types/PoolKey.sol"; +import {CLPoolParametersHelper} from "pancake-v4-core/src/pool-cl/libraries/CLPoolParametersHelper.sol"; +import {Currency} from "pancake-v4-core/src/types/Currency.sol"; +import {IPoolManager} from "pancake-v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "pancake-v4-core/src/interfaces/IHooks.sol"; +import {PoolId, PoolIdLibrary} from "pancake-v4-core/src/types/PoolId.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {IV3NonfungiblePositionManager} from "../../../src/interfaces/external/IV3NonfungiblePositionManager.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +interface IPancakeV3LikePairFactory { + function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); +} + +abstract contract CLMigratorFromV3 is OldVersionHelper, GasSnapshot { + using CLPoolParametersHelper for bytes32; + using PoolIdLibrary for PoolKey; + + uint160 public constant INIT_SQRT_PRICE = 79228162514264337593543950336; + + WETH weth; + MockERC20 token0; + MockERC20 token1; + + Vault vault; + CLPoolManager poolManager; + NonfungiblePositionManager nonfungiblePoolManager; + ICLMigrator migrator; + PoolKey poolKey; + PoolKey poolKeyWithoutNativeToken; + + IPancakeV3LikePairFactory v3Factory; + IV3NonfungiblePositionManager v3Nfpm; + + function _getDeployerBytecodePath() internal pure virtual returns (string memory); + function _getFactoryBytecodePath() internal pure virtual returns (string memory); + function _getNfpmBytecodePath() internal pure virtual returns (string memory); + + function _getContractName() internal pure virtual returns (string memory); + + function setUp() public { + weth = new WETH(); + token0 = new MockERC20("Token0", "TKN0", 18); + token1 = new MockERC20("Token1", "TKN1", 18); + (token0, token1) = token0 < token1 ? (token0, token1) : (token1, token0); + + // init v4 nfpm & migrator + vault = new Vault(); + poolManager = new CLPoolManager(vault, 3000); + vault.registerApp(address(poolManager)); + nonfungiblePoolManager = new NonfungiblePositionManager(vault, poolManager, address(0), address(weth)); + migrator = new CLMigrator(address(weth), address(nonfungiblePoolManager)); + + poolKey = PoolKey({ + // WETH after migration will be native token + currency0: Currency.wrap(address(0)), + currency1: Currency.wrap(address(token0)), + hooks: IHooks(address(0)), + poolManager: poolManager, + fee: 0, + parameters: bytes32(0).setTickSpacing(10) + }); + + poolKeyWithoutNativeToken = poolKey; + poolKeyWithoutNativeToken.currency0 = Currency.wrap(address(token0)); + poolKeyWithoutNativeToken.currency1 = Currency.wrap(address(token1)); + + // make sure the contract has enough balance + // WETH: 100 ether + // Token: 100 ether + // ETH: 90 ether + deal(address(this), 1000 ether); + weth.deposit{value: 100 ether}(); + token0.mint(address(this), 100 ether); + token1.mint(address(this), 100 ether); + + // pcs v3 + if (bytes(_getDeployerBytecodePath()).length != 0) { + address deployer = createContractThroughBytecode(_getDeployerBytecodePath()); + v3Factory = IPancakeV3LikePairFactory( + createContractThroughBytecode(_getFactoryBytecodePath(), toBytes32(address(deployer))) + ); + (bool success,) = deployer.call(abi.encodeWithSignature("setFactoryAddress(address)", address(v3Factory))); + require(success, "setFactoryAddress failed"); + v3Nfpm = IV3NonfungiblePositionManager( + createContractThroughBytecode( + _getNfpmBytecodePath(), + toBytes32(deployer), + toBytes32(address(v3Factory)), + toBytes32(address(weth)), + 0 + ) + ); + } else { + v3Factory = IPancakeV3LikePairFactory(createContractThroughBytecode(_getFactoryBytecodePath())); + + v3Nfpm = IV3NonfungiblePositionManager( + createContractThroughBytecode( + _getNfpmBytecodePath(), toBytes32(address(v3Factory)), toBytes32(address(weth)), 0 + ) + ); + } + + // make sure v3Nfpm has allowance + weth.approve(address(v3Nfpm), type(uint256).max); + token0.approve(address(v3Nfpm), type(uint256).max); + token1.approve(address(v3Nfpm), type(uint256).max); + } + + function testMigrateFromV3IncludingInit() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 3. multicall, combine initialize and migrateFromV3 + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, INIT_SQRT_PRICE, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4MintParams, 0, 0); + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV3IncludingInit"))); + migrator.multicall(data); + snapEnd(); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + assertEq(liquidity, 2005104164790028032677); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + } + + function testMigrateFromV3TokenMismatch() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + // v3 weth, token0 + // v4 ETH, token1 + PoolKey memory poolKeyMismatch = poolKey; + poolKeyMismatch.currency1 = Currency.wrap(address(token1)); + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKeyMismatch, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 3. multicall, combine initialize and migrateFromV3 + bytes[] memory data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, INIT_SQRT_PRICE, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4MintParams, 0, 0); + vm.expectRevert(); + migrator.multicall(data); + + { + // v3 weth, token0 + // v4 token0, token1 + poolKeyMismatch.currency0 = Currency.wrap(address(token0)); + poolKeyMismatch.currency1 = Currency.wrap(address(token1)); + v4MintParams.poolKey = poolKeyMismatch; + data = new bytes[](2); + data[0] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, INIT_SQRT_PRICE, bytes("")); + data[1] = abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4MintParams, 0, 0); + vm.expectRevert(); + migrator.multicall(data); + } + } + + function testMigrateFromV3WithoutInit() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + nonfungiblePoolManager.initialize(poolKey, INIT_SQRT_PRICE, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 4. migrateFromV3 directly given pool has been initialized + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV3WithoutInit"))); + migrator.migrateFromV3(v3PoolParams, v4MintParams, 0, 0); + snapEnd(); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + assertEq(liquidity, 2005104164790028032677); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + } + + function testMigrateFromV3WithoutNativeToken() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(token0), address(token1)); + + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. initialize the pool + migrator.initialize(poolKeyWithoutNativeToken, INIT_SQRT_PRICE, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKeyWithoutNativeToken, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 4. migrate from v3 to v4 + snapStart(string(abi.encodePacked(_getContractName(), "#testMigrateFromV3WithoutNativeToken"))); + migrator.migrateFromV3(v3PoolParams, v4MintParams, 0, 0); + snapEnd(); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + assertEq(liquidity, 2005104164790028032677); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + assertApproxEqAbs(token1.balanceOf(address(vault)), 10 ether, 0.000001 ether); + } + + function testMigrateFromV3AddExtraAmount() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + nonfungiblePoolManager.initialize(poolKey, INIT_SQRT_PRICE, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + IERC20(address(token0)).approve(address(migrator), 20 ether); + // 4. migrate from v3 to v4 + migrator.migrateFromV3{value: 20 ether}(v3PoolParams, v4MintParams, 20 ether, 20 ether); + + // necessary checks + // consumed extra 20 ether from user + assertApproxEqAbs(balance0Before - address(this).balance, 20 ether, 0.000001 ether); + assertEq(balance1Before - token0.balanceOf(address(this)), 20 ether); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 90 ether); + + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + // liquidity is 3 times of the original + assertApproxEqAbs(liquidity, 2005104164790028032677 * 3, 0.000001 ether); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 30 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 30 ether, 0.000001 ether); + } + + function testMigrateFromV3AddExtraAmountThroughWETH() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + nonfungiblePoolManager.initialize(poolKey, INIT_SQRT_PRICE, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + weth.approve(address(migrator), 20 ether); + IERC20(address(token0)).approve(address(migrator), 20 ether); + // 4. migrate from v3 to v4, not sending ETH denotes pay by WETH + migrator.migrateFromV3(v3PoolParams, v4MintParams, 20 ether, 20 ether); + + // necessary checks + // consumed extra 20 ether from user + // native token balance unchanged + assertApproxEqAbs(balance0Before - address(this).balance, 0 ether, 0.000001 ether); + assertEq(balance1Before - token0.balanceOf(address(this)), 20 ether); + // consumed 20 ether WETH + assertEq(weth.balanceOf(address(this)), 70 ether); + + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + // liquidity is 3 times of the original + assertApproxEqAbs(liquidity, 2005104164790028032677 * 3, 0.000001 ether); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 30 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 30 ether, 0.000001 ether); + } + + function testMigrateFromV3Refund() public { + // 1. mint some liquidity to the v3 pool + // 10 ether WETH, 5 ether token0 + _mintV3Liquidity(address(weth), address(token0), 10 ether, 5 ether); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + nonfungiblePoolManager.initialize(poolKey, INIT_SQRT_PRICE, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 0, + amount1Min: 0, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + uint256 balance0Before = address(this).balance; + uint256 balance1Before = token0.balanceOf(address(this)); + + // 4. migrate from v3 to v4, not sending ETH denotes pay by WETH + migrator.migrateFromV3(v3PoolParams, v4MintParams, 0, 0); + + // necessary checks + // refund 5 ether in the form of native token + assertApproxEqAbs(address(this).balance - balance0Before, 5.0 ether, 0.1 ether); + assertEq(balance1Before - token0.balanceOf(address(this)), 0 ether); + // WETH balance unchanged + assertApproxEqAbs(weth.balanceOf(address(this)), 90 ether, 0.1 ether); + + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + // liquidity is half of the original + assertApproxEqAbs(liquidity * 2, 2005104164790028032677, 0.1 ether); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 5 ether, 0.1 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 5 ether, 0.1 ether); + } + + function testMigrateFromV3RefundNonNativeToken() public { + // 1. mint some liquidity to the v3 pool + // 10 ether token0, 5 ether token1 + _mintV3Liquidity(address(token0), address(token1), 10 ether, 5 ether); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + nonfungiblePoolManager.initialize(poolKeyWithoutNativeToken, INIT_SQRT_PRICE, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 0, + amount1Min: 0, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKeyWithoutNativeToken, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + uint256 balance0Before = token0.balanceOf(address(this)); + uint256 balance1Before = token1.balanceOf(address(this)); + + // 4. migrate from v3 to v4 + migrator.migrateFromV3(v3PoolParams, v4MintParams, 0, 0); + + // necessary checks + + // refund 5 ether of token0 + assertApproxEqAbs(token0.balanceOf(address(this)) - balance0Before, 5 ether, 0.1 ether); + assertEq(balance1Before - token1.balanceOf(address(this)), 0 ether); + // WETH balance unchanged + assertEq(weth.balanceOf(address(this)), 100 ether); + + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKeyWithoutNativeToken.toId())); + assertEq(Currency.unwrap(currency0), address(token0)); + assertEq(Currency.unwrap(currency1), address(token1)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + // liquidity is half of the original + assertApproxEqAbs(liquidity * 2, 2005104164790028032677, 0.1 ether); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(token0.balanceOf(address(vault)), 5 ether, 0.1 ether); + assertApproxEqAbs(token1.balanceOf(address(vault)), 5 ether, 0.1 ether); + } + + function testMigrateFromV3FromNonOwner() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token + v3Nfpm.approve(address(migrator), 1); + + // 3. init the pool + nonfungiblePoolManager.initialize(poolKey, INIT_SQRT_PRICE, bytes("")); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + // half of the liquidity + liquidity: liquidityFromV3Before / 2, + amount0Min: 9.9 ether / 2, + amount1Min: 9.9 ether / 2, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 4. migrate half + migrator.migrateFromV3(v3PoolParams, v4MintParams, 0, 0); + + // make sure there are still liquidity left in v3 position token + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, liquidityFromV3Before - liquidityFromV3Before / 2); + + // 5. make sure non-owner can't migrate the rest + vm.expectRevert(IBaseMigrator.NOT_TOKEN_OWNER.selector); + vm.prank(makeAddr("someone")); + migrator.migrateFromV3(v3PoolParams, v4MintParams, 0, 0); + } + + function testMigrateFromV3ThroughOffchainSign() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (uint96 nonce,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token through offchain sign + // v3Nfpm.approve(address(migrator), 1); + (address userAddr, uint256 userPrivateKey) = makeAddrAndKey("user"); + + // 2.a transfer the lp token to the user + v3Nfpm.transferFrom(address(this), userAddr, 1); + + uint256 ddl = block.timestamp + 100; + // 2.b prepare the hash + bytes32 structHash = keccak256(abi.encode(v3Nfpm.PERMIT_TYPEHASH(), address(migrator), 1, nonce, ddl)); + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", v3Nfpm.DOMAIN_SEPARATOR(), structHash)); + + // 2.c generate the signature + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // 3. multicall, combine selfPermitERC721, initialize and migrateFromV3 + bytes[] memory data = new bytes[](3); + data[0] = abi.encodeWithSelector(migrator.selfPermitERC721.selector, v3Nfpm, 1, ddl, v, r, s); + data[1] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, INIT_SQRT_PRICE, bytes("")); + data[2] = abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4MintParams, 0, 0); + vm.prank(userAddr); + migrator.multicall(data); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + assertEq(liquidity, 2005104164790028032677); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 10 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 10 ether, 0.000001 ether); + } + + function testMigrateFromV3ThroughOffchainSignPayWithETH() public { + // 1. mint some liquidity to the v3 pool + _mintV3Liquidity(address(weth), address(token0)); + assertEq(v3Nfpm.ownerOf(1), address(this)); + (uint96 nonce,,,,,,, uint128 liquidityFromV3Before,,,,) = v3Nfpm.positions(1); + assertGt(liquidityFromV3Before, 0); + + // 2. make sure migrator can transfer user's v3 lp token through offchain sign + // v3Nfpm.approve(address(migrator), 1); + (address userAddr, uint256 userPrivateKey) = makeAddrAndKey("user"); + + // 2.a transfer the lp token to the user + v3Nfpm.transferFrom(address(this), userAddr, 1); + + uint256 ddl = block.timestamp + 100; + // 2.b prepare the hash + bytes32 structHash = keccak256(abi.encode(v3Nfpm.PERMIT_TYPEHASH(), address(migrator), 1, nonce, ddl)); + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", v3Nfpm.DOMAIN_SEPARATOR(), structHash)); + + // 2.c generate the signature + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash); + + IBaseMigrator.V3PoolParams memory v3PoolParams = IBaseMigrator.V3PoolParams({ + nfp: address(v3Nfpm), + tokenId: 1, + liquidity: liquidityFromV3Before, + amount0Min: 9.9 ether, + amount1Min: 9.9 ether, + collectFee: false, + deadline: block.timestamp + 100 + }); + + ICLMigrator.V4CLPoolParams memory v4MintParams = ICLMigrator.V4CLPoolParams({ + poolKey: poolKey, + tickLower: -100, + tickUpper: 100, + salt: bytes32(0), + amount0Min: 0 ether, + amount1Min: 0 ether, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + // make the guy rich + token0.transfer(userAddr, 10 ether); + deal(userAddr, 10 ether); + + vm.prank(userAddr); + token0.approve(address(migrator), 10 ether); + + // 3. multicall, combine selfPermitERC721, initialize and migrateFromV3 + bytes[] memory data = new bytes[](3); + data[0] = abi.encodeWithSelector(migrator.selfPermitERC721.selector, v3Nfpm, 1, ddl, v, r, s); + data[1] = abi.encodeWithSelector(migrator.initialize.selector, poolKey, INIT_SQRT_PRICE, bytes("")); + data[2] = + abi.encodeWithSelector(migrator.migrateFromV3.selector, v3PoolParams, v4MintParams, 10 ether, 10 ether); + vm.prank(userAddr); + migrator.multicall{value: 10 ether}(data); + + // necessary checks + // v3 liqudity should be 0 + (,,,,,,, uint128 liquidityFromV3After,,,,) = v3Nfpm.positions(1); + assertEq(liquidityFromV3After, 0); + + // make sure liuqidty is minted to the correct pool + assertEq(nonfungiblePoolManager.ownerOf(1), address(this)); + ( + , + , + PoolId poolId, + Currency currency0, + Currency currency1, + uint24 fee, + int24 tickLower, + int24 tickUpper, + uint128 liquidity, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint128 tokensOwed0, + uint128 tokensOwed1, + bytes32 salt + ) = nonfungiblePoolManager.positions(1); + + assertEq(PoolId.unwrap(poolId), PoolId.unwrap(poolKey.toId())); + assertEq(Currency.unwrap(currency0), address(0)); + assertEq(Currency.unwrap(currency1), address(token0)); + assertEq(fee, 0); + assertEq(tickLower, -100); + assertEq(tickUpper, 100); + assertEq(liquidity, 4010208329580056065555); + assertEq(feeGrowthInside0LastX128, 0); + assertEq(feeGrowthInside1LastX128, 0); + assertEq(tokensOwed0, 0); + assertEq(tokensOwed1, 0); + assertEq(salt, bytes32(0)); + assertApproxEqAbs(address(vault).balance, 20 ether, 0.000001 ether); + assertApproxEqAbs(token0.balanceOf(address(vault)), 20 ether, 0.000001 ether); + } + + function _mintV3Liquidity(address _token0, address _token1) internal { + (_token0, _token1) = _token0 < _token1 ? (_token0, _token1) : (_token1, _token0); + v3Nfpm.createAndInitializePoolIfNecessary(_token0, _token1, 500, INIT_SQRT_PRICE); + IV3NonfungiblePositionManager.MintParams memory mintParams = IV3NonfungiblePositionManager.MintParams({ + token0: _token0, + token1: _token1, + fee: 500, + tickLower: -100, + tickUpper: 100, + amount0Desired: 10 ether, + amount1Desired: 10 ether, + amount0Min: 0, + amount1Min: 0, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + v3Nfpm.mint(mintParams); + } + + function _mintV3Liquidity(address _token0, address _token1, uint256 amount0, uint256 amount1) internal { + int24 tickLower; + int24 tickUpper; + if (_token0 < _token1) { + tickLower = -100; + tickUpper = 200; + } else { + (_token0, _token1) = (_token1, _token0); + (amount0, amount1) = (amount1, amount0); + tickLower = -200; + tickUpper = 100; + } + v3Nfpm.createAndInitializePoolIfNecessary(_token0, _token1, 500, INIT_SQRT_PRICE); + + IV3NonfungiblePositionManager.MintParams memory mintParams = IV3NonfungiblePositionManager.MintParams({ + token0: _token0, + token1: _token1, + fee: 500, + tickLower: tickLower, + tickUpper: tickUpper, + amount0Desired: amount0, + amount1Desired: amount1, + amount0Min: amount0 - 0.1 ether, + amount1Min: amount1 - 0.1 ether, + recipient: address(this), + deadline: block.timestamp + 100 + }); + + v3Nfpm.mint(mintParams); + } + + receive() external payable {} +}