diff --git a/script/10_DeployBinPositionManagerHelper.s.sol b/script/10_DeployBinPositionManagerHelper.s.sol new file mode 100644 index 0000000..72bb84d --- /dev/null +++ b/script/10_DeployBinPositionManagerHelper.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import {BaseScript} from "./BaseScript.sol"; +import {IBinPoolManager} from "infinity-core/src/pool-bin/interfaces/IBinPoolManager.sol"; +import {IBinPositionManagerWithERC1155} from "../src/pool-bin/interfaces/IBinPositionManagerWithERC1155.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {IWETH9} from "../src/interfaces/external/IWETH9.sol"; +import {Create3Factory} from "pancake-create3-factory/src/Create3Factory.sol"; +import {BinPositionManagerHelper} from "../src/pool-bin/BinPositionManagerHelper.sol"; + +/** + * Pre-req: foundry on stable (1.0) otherwise verify will fail: ref https://github.com/foundry-rs/foundry/issues/9698 + * + * Step 1: Deploy + * forge script script/10_DeployBinPositionManagerHelper.s.sol:DeployBinPositionManagerHelper -vvv \ + * --rpc-url $RPC_URL \ + * --broadcast \ + * --slow \ + * --verify + */ +contract DeployBinPositionManagerHelper is BaseScript { + function getDeploymentSalt() public pure override returns (bytes32) { + return keccak256("INFINITY-PERIPHERY/BinPositionManagerHelper/1.0.0"); + } + + function run() public { + Create3Factory factory = Create3Factory(getAddressFromConfig("create3Factory")); + + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + + address binPoolManager = getAddressFromConfig("binPoolManager"); + emit log_named_address("binPoolManager", binPoolManager); + + address binPositionManager = getAddressFromConfig("binPositionManager"); + emit log_named_address("binPositionManager", binPositionManager); + + address permit2 = getAddressFromConfig("permit2"); + emit log_named_address("Permit2", permit2); + + address weth = getAddressFromConfig("weth"); + emit log_named_address("WETH", weth); + + bytes memory creationCodeData = abi.encode( + IBinPoolManager(binPoolManager), + IBinPositionManagerWithERC1155(binPositionManager), + IAllowanceTransfer(permit2), + IWETH9(weth) + ); + bytes memory creationCode = abi.encodePacked(type(BinPositionManagerHelper).creationCode, creationCodeData); + address binPositionManagerHelper = + factory.deploy(getDeploymentSalt(), creationCode, keccak256(creationCode), 0, new bytes(0), 0); + emit log_named_address("BinPositionManagerHelper", binPositionManagerHelper); + + vm.stopBroadcast(); + } +} diff --git a/script/config/base-mainnet.json b/script/config/base-mainnet.json index 9570df1..8fbdc3e 100644 --- a/script/config/base-mainnet.json +++ b/script/config/base-mainnet.json @@ -19,5 +19,6 @@ "factoryV2": "0x02a84c1b3BBD7401a5f7fa98a384EBC70bB5749E", "factoryStable": "0x64D74e1EAAe3176744b5767b93B7Bee39Cf7898F", "mixedQuoter": "0x2dCbF7B985c8C5C931818e4E107bAe8aaC8dAB7C", - "clTickLens": "0x8bcf30285413f25032fb983c2bf4defe29a33f3a" + "clTickLens": "0x8bcf30285413f25032fb983c2bf4defe29a33f3a", + "binPositionManagerHelper": "0xF4c3E0A83Ed93f6be55634C979e71c46c2453D4C" } diff --git a/script/config/bsc-mainnet.json b/script/config/bsc-mainnet.json index 929b440..4da193d 100644 --- a/script/config/bsc-mainnet.json +++ b/script/config/bsc-mainnet.json @@ -19,5 +19,6 @@ "factoryV2": "0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73", "factoryStable": "0x25a55f9f2279A54951133D503490342b50E5cd15", "mixedQuoter": "0x2dCbF7B985c8C5C931818e4E107bAe8aaC8dAB7C", - "clTickLens": "0x8BcF30285413F25032fb983C2bF4deFe29a33f3a" + "clTickLens": "0x8BcF30285413F25032fb983C2bF4deFe29a33f3a", + "binPositionManagerHelper": "0xF4c3E0A83Ed93f6be55634C979e71c46c2453D4C" } diff --git a/script/config/bsc-testnet.json b/script/config/bsc-testnet.json index 46f43bd..c932ae0 100644 --- a/script/config/bsc-testnet.json +++ b/script/config/bsc-testnet.json @@ -19,5 +19,6 @@ "factoryV2": "0x6725F303b657a9451d8BA641348b6761A6CC7a17", "factoryStable": "0xe6A00f8b819244e8Ab9Ea930e46449C2F20B6609", "mixedQuoter": "0xdf70a0A2DADC2a01dbE165702aa6dCdf034628b0", - "clTickLens": "0x05a732bb9A23256F57f9FdF255212979368Ece39" + "clTickLens": "0x05a732bb9A23256F57f9FdF255212979368Ece39", + "binPositionManagerHelper": "0xF4c3E0A83Ed93f6be55634C979e71c46c2453D4C" } \ No newline at end of file diff --git a/script/config/ethereum-mainnet.json b/script/config/ethereum-mainnet.json index 71cd903..623f7ca 100755 --- a/script/config/ethereum-mainnet.json +++ b/script/config/ethereum-mainnet.json @@ -13,5 +13,6 @@ "binQuoter": "0x", "clMigrator": "0x", "binMigrator": "0x", - "clTickLens": "0x" + "clTickLens": "0x", + "binPositionManagerHelper": "0x" } diff --git a/script/config/ethereum-sepolia.json b/script/config/ethereum-sepolia.json index 4e4cb96..41cb99d 100755 --- a/script/config/ethereum-sepolia.json +++ b/script/config/ethereum-sepolia.json @@ -17,5 +17,6 @@ "factoryV2": "0x1bdc540dEB9Ed1fA29964DeEcCc524A8f5e2198e", "factoryStable": "0x000000000000000000000000000000000000dEaD", "mixedQuoter": "0x3F47BDA24e069FC5f28B74d4FAB27EeBA006eF92", - "clTickLens": "0xE95fce3eEFcFf86B1d6a8951405432eA96505c98" + "clTickLens": "0xE95fce3eEFcFf86B1d6a8951405432eA96505c98", + "binPositionManagerHelper": "0x" } diff --git a/snapshots/BinPositionManagerHelperTest.json b/snapshots/BinPositionManagerHelperTest.json new file mode 100644 index 0000000..2ce0891 --- /dev/null +++ b/snapshots/BinPositionManagerHelperTest.json @@ -0,0 +1,8 @@ +{ + "BinPositionManagerHelper size": "8702", + "test_addLiquidities_existingPool": "1044057", + "test_addLiquidities_existingPool_bobReceiver": "1044057", + "test_addLiquidities_existingPool_nativeToken": "962326", + "test_addLiquidities_newPool": "1168114", + "test_addLiquidities_newPool_WithPermit": "1227164" +} \ No newline at end of file diff --git a/src/pool-bin/BinPositionManagerHelper.sol b/src/pool-bin/BinPositionManagerHelper.sol new file mode 100644 index 0000000..c85a2f3 --- /dev/null +++ b/src/pool-bin/BinPositionManagerHelper.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2024 PancakeSwap +pragma solidity 0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {Currency, CurrencyLibrary} from "infinity-core/src/types/Currency.sol"; +import {IBinPoolManager} from "infinity-core/src/pool-bin/interfaces/IBinPoolManager.sol"; +import {PoolKey} from "infinity-core/src/types/PoolKey.sol"; +import {PoolId} from "infinity-core/src/types/PoolId.sol"; +import {IVault} from "infinity-core/src/interfaces/IVault.sol"; + +import {IBinPositionManager} from "./interfaces/IBinPositionManager.sol"; +import {IBinPositionManagerWithERC1155} from "./interfaces/IBinPositionManagerWithERC1155.sol"; +import {IWETH9} from "../interfaces/external/IWETH9.sol"; +import {Actions} from "../libraries/Actions.sol"; +import {BinCalldataDecoder} from "./libraries/BinCalldataDecoder.sol"; +import {CalldataDecoder} from "../libraries/CalldataDecoder.sol"; +import {BinTokenLibrary} from "./libraries/BinTokenLibrary.sol"; +import {Multicall} from "../base/Multicall.sol"; +import {Permit2Forwarder} from "../base/Permit2Forwarder.sol"; +import {ReentrancyLock} from "../base/ReentrancyLock.sol"; + +/// @title BinPositionManagerHelper +/// @notice Helper contract for adding liquidity to bin pool with additional slippage protection +contract BinPositionManagerHelper is Multicall, Permit2Forwarder, ReentrancyLock { + using CalldataDecoder for bytes; + using BinCalldataDecoder for bytes; + using BinTokenLibrary for PoolId; + + /// @notice Thrown when an unexpected address sends ETH to this contract + error InvalidEthSender(); + /// @notice Thrown when theres multiple BIN_ADD_LIQUIDITY actions + error DuplicateAddLiquidity(); + /// @notice Thrown when there's unsupported action in the payload + error UnsupportedAction(); + /// @notice Thrown when no BIN_ADD_LIQUIDITY action is found in the payload + error NoAddLiquidityAction(); + /// @notice Thrown when minLiquidityParam's binIds and minLiquidities length mismatch + error MinLiquidityParamsLengthMismatch(); + /// @notice Thrown when slippage checks fail + error SlippageCheck(uint24 binId, uint256 liquidityAdded); + /// @notice Thrown when invalid (duplicate or non accending) binIds are found in minLiquidityParam + error InvalidBinId(uint24 binid); + + struct MinLiquidityParams { + /// @dev expect accending order of binIds eg. [20, 21, 22] + uint24[] binIds; + uint256[] minLiquidities; + } + + IBinPoolManager public immutable binPoolManager; + IBinPositionManagerWithERC1155 public immutable binPositionManager; + IWETH9 public immutable WETH9; + + constructor( + IBinPoolManager _binPoolManager, + IBinPositionManagerWithERC1155 _binPositionManager, + IAllowanceTransfer _permit2, + IWETH9 _weth9 + ) Permit2Forwarder(_permit2) { + binPoolManager = _binPoolManager; + binPositionManager = _binPositionManager; + permit2 = _permit2; + WETH9 = _weth9; + } + + /// @notice Add liquidities to bin pool with slippage protection + /// @param payload - encoded actions and parameters for bin position manager + /// @param deadline - deadline for the transaction + /// @param minLiquidityParam - amount of [binId, liquidity] to mint + /// @dev This function only support 1 BIN_ADD_LIQUIDITY call + function addLiquidities(bytes calldata payload, uint256 deadline, MinLiquidityParams memory minLiquidityParam) + external + payable + isNotLocked + { + if (minLiquidityParam.binIds.length != minLiquidityParam.minLiquidities.length) { + revert MinLiquidityParamsLengthMismatch(); + } + + // Step 1: decode payload + IBinPositionManager.BinAddLiquidityParams memory liquidityParams = _getAddLiquidityParam(payload); + Currency currency0 = liquidityParams.poolKey.currency0; + Currency currency1 = liquidityParams.poolKey.currency1; + + // Step 2: Transfer token from user and permit2 approve so BinPositionManager can take the tokens later + if (!currency0.isNative()) { + // for native case, do not need to check msg.value, as binPositionManager will revert + permit2.transferFrom(msg.sender, address(this), liquidityParams.amount0Max, Currency.unwrap(currency0)); + _approveBinPm(currency0, liquidityParams.amount0Max); + } + permit2.transferFrom(msg.sender, address(this), liquidityParams.amount1Max, Currency.unwrap(currency1)); + _approveBinPm(currency1, liquidityParams.amount1Max); + + // Step 3a: Before Check user balance before + address[] memory owners = new address[](minLiquidityParam.binIds.length); + uint256[] memory tokenIds = new uint256[](minLiquidityParam.binIds.length); + PoolId poolId = liquidityParams.poolKey.toId(); + uint24 tempBinId = 0; // check duplicate binId + for (uint256 i = 0; i < minLiquidityParam.binIds.length; i++) { + owners[i] = liquidityParams.to; + // Sanity check -- eg. assume binId is accending order, so this will check duplicate as well + if (tempBinId >= minLiquidityParam.binIds[i]) revert InvalidBinId(minLiquidityParam.binIds[i]); + tempBinId = minLiquidityParam.binIds[i]; + tokenIds[i] = poolId.toTokenId(tempBinId); + } + uint256[] memory balBefore = binPositionManager.balanceOfBatch(owners, tokenIds); + + // Step 3b: modify liquidities + binPositionManager.modifyLiquidities{value: msg.value}(payload, deadline); + + // Step 3c: Check user balance after + uint256[] memory balAfter = binPositionManager.balanceOfBatch(owners, tokenIds); + for (uint256 i = 0; i < minLiquidityParam.minLiquidities.length; i++) { + uint256 liquidityAdded = balAfter[i] - balBefore[i]; + if (liquidityAdded < minLiquidityParam.minLiquidities[i]) { + revert SlippageCheck(minLiquidityParam.binIds[i], liquidityAdded); + } + } + + // Step 4: refund the user of any balance t0, t1 in contract + if (currency0.balanceOfSelf() > 0) { + currency0.transfer(msg.sender, currency0.balanceOfSelf()); + } + if (currency1.balanceOfSelf() > 0) { + currency1.transfer(msg.sender, currency1.balanceOfSelf()); + } + } + + /// @notice Initialize a infinity PCS bin pool + /// @dev For a new pool, user will use multiCall[initializePool, addLiquidities], implementation copied from BinPositionManager + function initializePool(PoolKey memory key, uint24 activeId) external payable { + /// @dev if the pool revert due to other error (currencyOutOfOrder etc..), then the follow-up action to the pool will still revert accordingly + try binPoolManager.initialize(key, activeId) {} catch {} + } + + /// @notice Approve the bin position manager to spend the currency + /// @dev assume currency is not native + function _approveBinPm(Currency _currency, uint160 _amount) internal { + IERC20(Currency.unwrap(_currency)).approve(address(permit2), _amount); + permit2.approve(Currency.unwrap(_currency), address(binPositionManager), _amount, uint48(block.timestamp)); + } + + function _getAddLiquidityParam(bytes calldata payload) + internal + pure + returns (IBinPositionManager.BinAddLiquidityParams memory liquidityParams) + { + (bytes calldata actions, bytes[] calldata params) = payload.decodeActionsRouterParams(); + uint256 numActions = actions.length; + for (uint256 actionIndex = 0; actionIndex < numActions; actionIndex++) { + uint256 action = uint8(actions[actionIndex]); + + if (action == Actions.BIN_ADD_LIQUIDITY) { + if (liquidityParams.activeIdDesired != 0) revert DuplicateAddLiquidity(); + liquidityParams = params[actionIndex].decodeBinAddLiquidityParams(); + } + + // FE won't call BIN_ADD_LIQUIDITY_FROM_DELTAS + if (action == Actions.BIN_ADD_LIQUIDITY_FROM_DELTAS || action == Actions.BIN_REMOVE_LIQUIDITY) { + revert UnsupportedAction(); + } + } + + if (liquidityParams.activeIdDesired == 0) { + revert NoAddLiquidityAction(); + } + + return liquidityParams; + } + + receive() external payable { + if (msg.sender != address(WETH9) && msg.sender != address(binPositionManager)) revert InvalidEthSender(); + } +} diff --git a/src/pool-bin/interfaces/IBinPositionManagerWithERC1155.sol b/src/pool-bin/interfaces/IBinPositionManagerWithERC1155.sol new file mode 100644 index 0000000..7e74da1 --- /dev/null +++ b/src/pool-bin/interfaces/IBinPositionManagerWithERC1155.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IBinPositionManager} from "./IBinPositionManager.sol"; + +interface IBinPositionManagerWithERC1155 is IBinPositionManager { + function balanceOfBatch(address[] calldata owners, uint256[] calldata ids) + external + returns (uint256[] memory balances); +} diff --git a/test/pool-bin/BinPositionManagerHelper.t.sol b/test/pool-bin/BinPositionManagerHelper.t.sol new file mode 100644 index 0000000..7731112 --- /dev/null +++ b/test/pool-bin/BinPositionManagerHelper.t.sol @@ -0,0 +1,550 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; +import {DeployPermit2} from "permit2/test/utils/DeployPermit2.sol"; + +import {Vault} from "infinity-core/src/Vault.sol"; +import {PoolKey} from "infinity-core/src/types/PoolKey.sol"; +import {IVault} from "infinity-core/src/interfaces/IVault.sol"; +import {IBinPoolManager} from "infinity-core/src/pool-bin/interfaces/IBinPoolManager.sol"; +import {BinPoolManager} from "infinity-core/src/pool-bin/BinPoolManager.sol"; +import {TokenFixture} from "infinity-core/test/helpers/TokenFixture.sol"; +import {IHooks} from "infinity-core/src/interfaces/IHooks.sol"; +import {BinPoolParametersHelper} from "infinity-core/src/pool-bin/libraries/BinPoolParametersHelper.sol"; +import {Currency, CurrencyLibrary} from "infinity-core/src/types/Currency.sol"; +import {BinHelper} from "infinity-core/src/pool-bin/libraries/BinHelper.sol"; +import {PriceHelper} from "infinity-core/src/pool-bin/libraries/PriceHelper.sol"; +import {IPositionManager} from "../../src/interfaces/IPositionManager.sol"; +import {BinPositionManager} from "../../src/pool-bin/BinPositionManager.sol"; +import {IBinPositionManager} from "../../src/pool-bin/interfaces/IBinPositionManager.sol"; +import {IWETH9} from "../../src/interfaces/external/IWETH9.sol"; +import {BinLiquidityHelper} from "./helper/BinLiquidityHelper.sol"; +import {Planner, Plan} from "../../src/libraries/Planner.sol"; +import {Actions} from "../../src/libraries/Actions.sol"; +import {ActionConstants} from "../../src/libraries/ActionConstants.sol"; +import {Permit2SignatureHelpers} from "../shared/Permit2SignatureHelpers.sol"; +import {Permit2Forwarder} from "../../src/base/Permit2Forwarder.sol"; + +import {BinPositionManagerHelper} from "../../src/pool-bin/BinPositionManagerHelper.sol"; +import {IBinPositionManagerWithERC1155} from "../../src/pool-bin/interfaces/IBinPositionManagerWithERC1155.sol"; + +contract BinPositionManagerHelperTest is + Test, + Permit2SignatureHelpers, + TokenFixture, + DeployPermit2, + BinLiquidityHelper +{ + using BinPoolParametersHelper for bytes32; + + error ContractSizeTooLarge(uint256 diff); + + bytes constant ZERO_BYTES = new bytes(0); + uint256 _deadline = block.timestamp + 1; + + Vault vault; + BinPoolManager poolManager; + BinPositionManager binPm; + IAllowanceTransfer permit2; + BinPositionManagerHelper binPmHelper; + + MockERC20 token0; + MockERC20 token1; + + PoolKey key1; // initialized pool + PoolKey key2; // uninitialized pool + PoolKey key3; // initialized pool with native eth + + bytes32 poolParam; + address alice = makeAddr("alice"); + uint24 activeId = 2 ** 23; // where token0 and token1 price is the same + + function setUp() public { + vault = new Vault(); + poolManager = new BinPoolManager(IVault(address(vault))); + vault.registerApp(address(poolManager)); + permit2 = IAllowanceTransfer(deployPermit2()); + initializeTokens(); + (token0, token1) = (MockERC20(Currency.unwrap(currency0)), MockERC20(Currency.unwrap(currency1))); + + binPm = new BinPositionManager( + IVault(address(vault)), IBinPoolManager(address(poolManager)), permit2, IWETH9(address(0)) + ); + + binPmHelper = new BinPositionManagerHelper( + IBinPoolManager(address(poolManager)), + IBinPositionManagerWithERC1155(address(binPm)), + permit2, + IWETH9(address(0)) + ); + + key1 = PoolKey({ + currency0: currency0, + currency1: currency1, + hooks: IHooks(address(0)), + poolManager: IBinPoolManager(address(poolManager)), + fee: uint24(1000), // 0.1% pool + parameters: poolParam.setBinStep(10) // binStep + }); + binPmHelper.initializePool(key1, activeId); + + key2 = PoolKey({ + currency0: currency0, + currency1: currency1, + hooks: IHooks(address(0)), + poolManager: IBinPoolManager(address(poolManager)), + fee: uint24(10_000), // 1% pool + parameters: poolParam.setBinStep(100) // binStep + }); + + key3 = PoolKey({ + currency0: CurrencyLibrary.NATIVE, + currency1: currency1, + hooks: IHooks(address(0)), + poolManager: IBinPoolManager(address(poolManager)), + fee: uint24(10_000), // 1% pool + parameters: poolParam.setBinStep(100) // binStep + }); + binPmHelper.initializePool(key3, activeId); + + // pre-req: alice approve permit2 -> binPmHelper for token0/token1 + approveBinPm(alice, key1, address(binPmHelper), permit2); + approveBinPm(alice, key3, address(binPmHelper), permit2); + } + + function test_bytecodeSize() public { + vm.snapshotValue("BinPositionManagerHelper size", address(binPmHelper).code.length); + + if (address(binPmHelper).code.length > 24576) { + revert ContractSizeTooLarge(address(binPmHelper).code.length - 24576); + } + } + + function test_addLiquidities_MinLiquidityParamsLengthMismatch() public { + // step 1: prepare param liquidity. means roughly 3 bins + // with the following tokens in each bin: [1.5 token1, 1.5 token1 + 1.5 token0, 1.5 token0] + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key1, binIds, 3 ether, 3 ether, activeId, alice); + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](2); + minLiquidityBinIds[0] = activeId; + minLiquiditys[0] = 1e18; + minLiquiditys[1] = 2e18; // mismatch length + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + vm.prank(alice); + vm.expectRevert(BinPositionManagerHelper.MinLiquidityParamsLengthMismatch.selector); + binPmHelper.addLiquidities(payload, _deadline, minLiquidityParam); + } + + function test_addLiquidities_InvalidBinId() public { + // before + token0.mint(alice, 4 ether); + token1.mint(alice, 4 ether); + assertEq(token0.balanceOf(alice), 4 ether); + assertEq(token1.balanceOf(alice), 4 ether); + + // step 1: prepare param liquidity. means roughly 3 bins + // with the following tokens in each bin: [1.5 token1, 1.5 token1 + 1.5 token0, 1.5 token0] + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key1, binIds, 3 ether, 3 ether, activeId, alice); + param.amount0Max = 3 ether * 1.1; // assume 10% slippage + param.amount1Max = 3 ether * 1.1; // assume 10% slippage + param.idSlippage = 0; // actveId is the same + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](2); + uint256[] memory minLiquiditys = new uint256[](2); + uint256 price = PriceHelper.getPriceFromId(activeId, key1.parameters.getBinStep()); + minLiquidityBinIds[0] = activeId; + minLiquidityBinIds[1] = activeId; // duplicate binId + minLiquiditys[0] = BinHelper.getLiquidity(1.5 ether, 1.5 ether, price) * 9999 / 10_000; // 0.01% slippage + minLiquiditys[1] = BinHelper.getLiquidity(1.5 ether, 1.5 ether, price) * 9999 / 10_000; // 0.01% slippage + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Step 3: prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + vm.expectRevert(abi.encodeWithSelector(BinPositionManagerHelper.InvalidBinId.selector, activeId)); + vm.prank(alice); + binPmHelper.addLiquidities(payload, _deadline, minLiquidityParam); + } + + function test_addLiquidities_existingPool() public { + // before + token0.mint(alice, 4 ether); + token1.mint(alice, 4 ether); + assertEq(token0.balanceOf(alice), 4 ether); + assertEq(token1.balanceOf(alice), 4 ether); + + // step 1: prepare param liquidity. means roughly 3 bins + // with the following tokens in each bin: [1.5 token1, 1.5 token1 + 1.5 token0, 1.5 token0] + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key1, binIds, 3 ether, 3 ether, activeId, alice); + param.amount0Max = 3 ether * 1.1; // assume 10% slippage + param.amount1Max = 3 ether * 1.1; // assume 10% slippage + param.idSlippage = 0; // actveId is the same + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](1); + uint256 price = PriceHelper.getPriceFromId(activeId, key1.parameters.getBinStep()); + minLiquidityBinIds[0] = activeId; + minLiquiditys[0] = BinHelper.getLiquidity(1.5 ether, 1.5 ether, price) * 9999 / 10_000; // 0.01% slippage + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Step 3: prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + vm.prank(alice); + binPmHelper.addLiquidities(payload, _deadline, minLiquidityParam); + vm.snapshotGasLastCall("test_addLiquidities_existingPool"); + + // after + assertEq(token0.balanceOf(alice), 1 ether); // initial 4 ether, then minus 3 ether added + assertEq(token1.balanceOf(alice), 1 ether); // initial 4 ether, then minus 3 ether added + } + + /// @dev mint to bob instead + function test_addLiquidities_existingPool_bobReceiver() public { + address bob = makeAddr("bob"); + + // before + token0.mint(alice, 4 ether); + token1.mint(alice, 4 ether); + assertEq(token0.balanceOf(alice), 4 ether); + assertEq(token1.balanceOf(alice), 4 ether); + + // step 1: prepare param liquidity. means roughly 3 bins + // with the following tokens in each bin: [1.5 token1, 1.5 token1 + 1.5 token0, 1.5 token0] + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key1, binIds, 3 ether, 3 ether, activeId, bob); + param.amount0Max = 3 ether * 1.1; // assume 10% slippage + param.amount1Max = 3 ether * 1.1; // assume 10% slippage + param.idSlippage = 0; // actveId is the same + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](1); + uint256 price = PriceHelper.getPriceFromId(activeId, key1.parameters.getBinStep()); + minLiquidityBinIds[0] = activeId; + minLiquiditys[0] = BinHelper.getLiquidity(1.5 ether, 1.5 ether, price) * 9999 / 10_000; // 0.01% slippage + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Step 3: prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + vm.prank(alice); + binPmHelper.addLiquidities(payload, _deadline, minLiquidityParam); + vm.snapshotGasLastCall("test_addLiquidities_existingPool_bobReceiver"); + + // after + assertEq(token0.balanceOf(alice), 1 ether); // initial 4 ether, then minus 3 ether added + assertEq(token1.balanceOf(alice), 1 ether); // initial 4 ether, then minus 3 ether added + } + + /// @dev ensure add liquidity works for pool with currency0 as native token + function test_addLiquidities_existingPool_nativeToken() public { + // before + vm.deal(alice, 4 ether); + token1.mint(alice, 4 ether); + assertEq(alice.balance, 4 ether); + assertEq(token1.balanceOf(alice), 4 ether); + + // step 1: prepare param liquidity. means roughly 3 bins + // with the following tokens in each bin: [1.5 token1, 1.5 token1 + 1.5 token0, 1.5 token0] + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key3, binIds, 3 ether, 3 ether, activeId, alice); + param.amount0Max = 3 ether * 1.1; // assume 10% slippage + param.amount1Max = 3 ether * 1.1; // assume 10% slippage + param.idSlippage = 0; // actveId is the same + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](1); + uint256 price = PriceHelper.getPriceFromId(activeId, key3.parameters.getBinStep()); + minLiquidityBinIds[0] = activeId; + minLiquiditys[0] = BinHelper.getLiquidity(1.5 ether, 1.5 ether, price) * 9999 / 10_000; // 0.01% slippage + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Step 3: prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(key3.currency0, key3.currency1)); + planner.add(Actions.SWEEP, abi.encode(CurrencyLibrary.NATIVE, ActionConstants.MSG_SENDER)); + bytes memory payload = planner.encode(); + vm.prank(alice); + binPmHelper.addLiquidities{value: 4 ether}(payload, _deadline, minLiquidityParam); + vm.snapshotGasLastCall("test_addLiquidities_existingPool_nativeToken"); + + // after + assertEq(alice.balance, 1 ether); // initial 4 ether, then minus 3 ether added + assertEq(token1.balanceOf(alice), 1 ether); // initial 4 ether, then minus 3 ether added + } + + function test_addLiquidities_existingPool_slippage() public { + // before + token0.mint(alice, 4 ether); + token1.mint(alice, 4 ether); + + // step 1: prepare param liquidity. means roughly 3 bins + // with the following tokens in each bin: [1.5 token1, 1.5 token1 + 1.5 token0, 1.5 token0] + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key1, binIds, 2 ether, 2 ether, activeId, alice); + param.amount0Max = 2 ether * 1.1; // assume 10% slippage + param.amount1Max = 2 ether * 1.1; // assume 10% slippage + param.idSlippage = 0; // actveId is the same + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](1); + uint256 price = PriceHelper.getPriceFromId(activeId, key1.parameters.getBinStep()); + minLiquidityBinIds[0] = activeId; + // expected 1 liquidity more than default. its in 128.128 format + minLiquiditys[0] = BinHelper.getLiquidity(1 ether, 1 ether, price) * 105 / 100; // expect 5% more + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Step 3: prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector( + BinPositionManagerHelper.SlippageCheck.selector, + minLiquidityBinIds[0], + 680564733841876926926749214863536422911999999999999999000 // roughly BinHelper.getLiquidity(1 ether, 1 ether, price) + ) + ); + binPmHelper.addLiquidities(payload, _deadline, minLiquidityParam); + } + + /// @dev example test with multiCall (to include initializePool) + function test_addLiquidities_newPool() public { + // before + token0.mint(alice, 4 ether); + token1.mint(alice, 4 ether); + assertEq(token0.balanceOf(alice), 4 ether); + assertEq(token1.balanceOf(alice), 4 ether); + + // step 1: prepare param liquidity. means roughly 3 bins + // with the following tokens in each bin: [1.5 token1, 1.5 token1 + 1.5 token0, 1.5 token0] + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key2, binIds, 3 ether, 3 ether, activeId, alice); + param.amount0Max = 3 ether * 1.1; // assume 10% slippage + param.amount1Max = 3 ether * 1.1; // assume 10% slippage + param.idSlippage = 0; // actveId is the same + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](1); + uint256 price = PriceHelper.getPriceFromId(activeId, key2.parameters.getBinStep()); + minLiquidityBinIds[0] = activeId; + minLiquiditys[0] = BinHelper.getLiquidity(1.5 ether, 1.5 ether, price) * 9999 / 10_000; // 0.01% slippage + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Step 3: prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSelector(binPmHelper.initializePool.selector, key2, activeId, ZERO_BYTES); + calls[1] = abi.encodeWithSelector(binPmHelper.addLiquidities.selector, payload, _deadline, minLiquidityParam); + vm.prank(alice); + binPmHelper.multicall(calls); + vm.snapshotGasLastCall("test_addLiquidities_newPool"); + + // after + assertEq(token0.balanceOf(alice), 1 ether); // initial 4 ether, then minus 3 ether added + assertEq(token1.balanceOf(alice), 1 ether); // initial 4 ether, then minus 3 ether added + } + + /// @dev example test with multiCall (to include initializePool and permit for new user) + function test_addLiquidities_newPool_WithPermit() public { + (address bob, uint256 bobPK) = makeAddrAndKey("bob"); + + // before, require bob to approve token to permit2 + token0.mint(bob, 4 ether); + token1.mint(bob, 4 ether); + assertEq(token0.balanceOf(bob), 4 ether); + assertEq(token1.balanceOf(bob), 4 ether); + vm.startPrank(bob); + token0.approve(address(permit2), type(uint256).max); + token1.approve(address(permit2), type(uint256).max); + vm.stopPrank(); + + // step 1: prepare param liquidity. means roughly 3 bins + // with the following tokens in each bin: [1.5 token1, 1.5 token1 + 1.5 token0, 1.5 token0] + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key2, binIds, 3 ether, 3 ether, activeId, bob); + param.amount0Max = 3 ether * 1.1; // assume 10% slippage + param.amount1Max = 3 ether * 1.1; // assume 10% slippage + param.idSlippage = 0; // actveId is the same + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](1); + uint256 price = PriceHelper.getPriceFromId(activeId, key2.parameters.getBinStep()); + minLiquidityBinIds[0] = activeId; + minLiquiditys[0] = BinHelper.getLiquidity(1.5 ether, 1.5 ether, price) * 9999 / 10_000; // 0.01% slippage + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // step 2b prepare the permit payload + uint160 permitAmount = type(uint160).max; + uint48 permitExpiration = uint48(block.timestamp + 10e18); + uint48 permitNonce = 0; + IAllowanceTransfer.PermitSingle memory permit0 = + defaultERC20PermitAllowance(Currency.unwrap(currency0), permitAmount, permitExpiration, permitNonce); + permit0.spender = address(binPmHelper); + bytes memory sig0 = getPermitSignature(permit0, bobPK, permit2.DOMAIN_SEPARATOR()); + IAllowanceTransfer.PermitSingle memory permit1 = + defaultERC20PermitAllowance(Currency.unwrap(currency1), permitAmount, permitExpiration, permitNonce); + permit1.spender = address(binPmHelper); + bytes memory sig1 = getPermitSignature(permit1, bobPK, permit2.DOMAIN_SEPARATOR()); + + // Step 3: prepare addLiquidities payload + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + + bytes[] memory calls = new bytes[](4); + calls[0] = abi.encodeWithSelector(binPmHelper.initializePool.selector, key2, activeId, ZERO_BYTES); + calls[1] = abi.encodeWithSelector(Permit2Forwarder.permit.selector, bob, permit0, sig0); + calls[2] = abi.encodeWithSelector(Permit2Forwarder.permit.selector, bob, permit1, sig1); + calls[3] = abi.encodeWithSelector(binPmHelper.addLiquidities.selector, payload, _deadline, minLiquidityParam); + vm.prank(bob); + binPmHelper.multicall(calls); + vm.snapshotGasLastCall("test_addLiquidities_newPool_WithPermit"); + + // after + assertEq(token0.balanceOf(bob), 1 ether); // initial 4 ether, then minus 3 ether added + assertEq(token1.balanceOf(bob), 1 ether); // initial 4 ether, then minus 3 ether added + } + + function test_addLiquidities_newPool_DuplicateAddLiquidity() external { + // step 1: prepare param liquidity. + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key1, binIds, 3 ether, 3 ether, activeId, alice); + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](1); + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Step 3: prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + vm.prank(alice); + vm.expectRevert(BinPositionManagerHelper.DuplicateAddLiquidity.selector); + binPmHelper.addLiquidities(payload, _deadline, minLiquidityParam); + } + + function test_addLiquidities_newPool_UnsupportedAction() external { + // step 1: prepare param liquidity. + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key1, binIds, 3 ether, 3 ether, activeId, alice); + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](1); + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Step 3: prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY_FROM_DELTAS, abi.encode(param)); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + vm.prank(alice); + vm.expectRevert(BinPositionManagerHelper.UnsupportedAction.selector); + binPmHelper.addLiquidities(payload, _deadline, minLiquidityParam); + } + + function test_addLiquidities_newPool_NoAddLiquidityAction() external { + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](1); + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.SETTLE_PAIR, abi.encode(currency0, currency1)); + bytes memory payload = planner.encode(); + vm.prank(alice); + vm.expectRevert(BinPositionManagerHelper.NoAddLiquidityAction.selector); + binPmHelper.addLiquidities(payload, _deadline, minLiquidityParam); + } + + function test_addLiquidities_newPool_MinLiquidityParamsLengthMismatch() external { + // step 1: prepare param liquidity. + uint24[] memory binIds = getBinIds(activeId, 3); + IBinPositionManager.BinAddLiquidityParams memory param = + _getAddParams(key1, binIds, 3 ether, 3 ether, activeId, alice); + + // step 2: prepare minLiquidity param -- since id slippage, is 0, we can just check activeId minLiquidity + uint24[] memory minLiquidityBinIds = new uint24[](1); + uint256[] memory minLiquiditys = new uint256[](2); + BinPositionManagerHelper.MinLiquidityParams memory minLiquidityParam = + BinPositionManagerHelper.MinLiquidityParams({binIds: minLiquidityBinIds, minLiquidities: minLiquiditys}); + + // Step 3: prepare and call + Plan memory planner = Planner.init(); + planner.add(Actions.BIN_ADD_LIQUIDITY, abi.encode(param)); + bytes memory payload = planner.encode(); + vm.prank(alice); + vm.expectRevert(BinPositionManagerHelper.MinLiquidityParamsLengthMismatch.selector); + binPmHelper.addLiquidities(payload, _deadline, minLiquidityParam); + } + + function _getMinLiquidityParam(uint24[] memory binIds, uint256[] memory minLiquidities) + internal + pure + returns (BinPositionManagerHelper.MinLiquidityParams memory) + { + require(binIds.length == minLiquidities.length, "BinPositionManagerHelper: length mismatch"); + + return BinPositionManagerHelper.MinLiquidityParams({binIds: binIds, minLiquidities: minLiquidities}); + } +} diff --git a/test/pool-bin/helper/BinLiquidityHelper.sol b/test/pool-bin/helper/BinLiquidityHelper.sol index d1cff0a..6b36a29 100644 --- a/test/pool-bin/helper/BinLiquidityHelper.sol +++ b/test/pool-bin/helper/BinLiquidityHelper.sol @@ -30,6 +30,9 @@ contract BinLiquidityHelper is Test { function approveBinPmForCurrency(address from, Currency currency, address binPm, IAllowanceTransfer permit2) internal { + // return early if currency is native, no need to approve + if (currency.isNative()) return; + vm.startPrank(from); // Because BinPm uses permit2, we must execute 2 permits/approvals.