diff --git a/.github/workflows/audit_agent.yml b/.github/workflows/audit_agent.yml index d7c7022..1645742 100644 --- a/.github/workflows/audit_agent.yml +++ b/.github/workflows/audit_agent.yml @@ -44,14 +44,71 @@ jobs: - name: Quick Scan if: steps.extract.outputs.should_scan == 'true' run: | - curl -X POST \ - -H "Content-Type: application/json" \ - -H "X-Api-Key: $AUDIT_AGENT_TOKEN" \ - -d '{ - "githubUrl": "${{ github.event.repository.html_url }}", - "branchName": "${{ github.event.pull_request.head.ref }}", - "issueNumber": ${{ github.event.number }}, - "commitHash": "${{ github.event.pull_request.head.sha }}", - "contractFiles": ${{ steps.extract.outputs.files }} - }' \ - https://api.auditagent.nethermind.io/api/v1/scanner/quick-scan/launch \ No newline at end of file + set -e + API_URL="https://api.auditagent.nethermind.io" + HTTP_CODE=$(curl -s -w "%{http_code}" -o launch_response.json -X POST -H "Content-Type: application/json" -H "X-Api-Key: $AUDIT_AGENT_TOKEN" -d '{ + "githubUrl": "${{ github.event.repository.html_url }}", + "baseBranchName": "${{ github.event.pull_request.base.ref }}", + "branchName": "${{ github.event.pull_request.head.ref }}", + "issueNumber": ${{ github.event.number }}, + "baseCommitHash": "${{ github.event.pull_request.base.sha }}", + "commitHash": "${{ github.event.pull_request.head.sha }}", + "contractFiles": ${{ steps.extract.outputs.files }}, + "language": "solidity" + }' "$API_URL/api/v1/scanner/quick-scan/diff-scan") + if [ "$HTTP_CODE" != "202" ]; then + echo "Launch failed. Expected 202, got $HTTP_CODE." + cat launch_response.json + exit 1 + fi + SCAN_ID=$(cat launch_response.json | tr -d '\000-\037' | jq -r '.data.scan_id // empty') + if [ -z "$SCAN_ID" ]; then + echo "No relevant changes found. No scan needed." + exit 0 + fi + echo "Scan started: $SCAN_ID" + while true; do + RESULT_JSON=$(curl -s -f -H "X-Api-Key: $AUDIT_AGENT_TOKEN" "$API_URL/api/v1/scans/ci-result/$SCAN_ID") + STATUS=$(echo "$RESULT_JSON" | tr -d '\000-\037' | jq -r '.data.scan.status // empty') + if [ "$STATUS" = "completed" ]; then + echo "Scan completed successfully." + exit 0 + fi + if [ "$STATUS" = "failed" ]; then + echo "Scan failed." + exit 1 + fi + echo "Scan status: $STATUS (waiting...)" + sleep 60 + done + merge-context: + if: ${{ github.event.action == 'closed' && github.event.pull_request.merged == true }} + runs-on: ubuntu-latest + env: + AUDIT_AGENT_TOKEN: ${{ secrets.AUDIT_AGENT_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Fetch latest target branch and get new commit hash + id: merged_commit + run: | + git fetch origin main:origin/main + NEW_COMMIT_HASH=$(git rev-parse origin/main) + echo "hash=$NEW_COMMIT_HASH" >> $GITHUB_OUTPUT + - name: Merge Context + run: | + RESPONSE=$(curl -s -w "%{http_code}" -o response.json -X POST -H "Content-Type: application/json" -H "X-Api-Key: $AUDIT_AGENT_TOKEN" -d '{ + "githubUrl": "${{ github.event.repository.html_url }}", + "baseBranchName": "${{ github.event.pull_request.base.ref }}", + "baseCommitHash": "${{ steps.merged_commit.outputs.hash }}", + "branchName": "${{ github.event.pull_request.head.ref }}", + "issueNumber": ${{ github.event.number }}, + "commitHash": "${{ github.event.pull_request.head.sha }}" + }' https://api.auditagent.nethermind.io/api/v1/scanner/quick-scan/merge) + STATUS_CODE="${RESPONSE: -3}" + if [ "$STATUS_CODE" != "202" ]; then + echo "API call failed. Expected 202, got $STATUS_CODE." + cat response.json + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f93599f..22626c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,10 +8,10 @@ on: workflow_dispatch: env: - ETH_NODE_URL: ${{ secrets.ETH_NODE_URL }} - BSC_NODE_URL: ${{ secrets.BSC_NODE_URL }} - SEPOLIA_NODE_URL: ${{ secrets.SEPOLIA_NODE_URL }} - OP_NODE_URL: ${{ secrets.OP_NODE_URL }} + RPC_1: ${{ secrets.RPC_1 }} + RPC_56: ${{ secrets.RPC_56 }} + RPC_11155111: ${{ secrets.RPC_11155111 }} + RPC_10: ${{ secrets.RPC_10 }} jobs: test: @@ -37,11 +37,14 @@ jobs: - name: Run tests run: forge test --nmt "testMockActionExecuteSuccessP256|testMockActionExecuteSuccessWebAuthn" id: test + if: always() - name: Run tests P256 run: forge test --mt testMockActionExecuteSuccessP256 --evm-version osaka id: test-p256 + if: always() - name: Run tests WebAuthn run: forge test --mt testMockActionExecuteSuccessWebAuthn --evm-version osaka - id: test-webauthn \ No newline at end of file + id: test-webauthn + if: always() diff --git a/foundry.toml b/foundry.toml index 21f4a0d..136e7d4 100644 --- a/foundry.toml +++ b/foundry.toml @@ -27,8 +27,10 @@ sort_imports = true runs = 20 [rpc_endpoints] -mainnet = "${ETH_NODE_URL}" -bsc_mainnet = "${BSC_NODE_URL}" +mainnet = "${RPC_1}" +bsc_mainnet = "${RPC_56}" +sepolia = "${RPC_11155111}" +optimism_mainnet = "${RPC_10}" [etherscan] mainnet = { key = "${ETHERSCAN_API_KEY}" } diff --git a/src/KSSmartIntentRouterAccounting.sol b/src/KSSmartIntentRouterAccounting.sol index 20128d9..41d9a0f 100644 --- a/src/KSSmartIntentRouterAccounting.sol +++ b/src/KSSmartIntentRouterAccounting.sol @@ -25,6 +25,8 @@ abstract contract KSSmartIntentRouterAccounting is using PermitHelper for address; mapping(bytes32 => mapping(address => uint256)) public erc20Allowances; + uint256 internal constant ERC721_WILDCARD_TOKEN_ID = type(uint256).max; + uint256 internal constant ERC721_IDS_SEPARATOR = type(uint256).max; /// @notice Set the tokens' allowances for the intent function _approveTokens(bytes32 intentHash, TokenData calldata tokenData, address mainAddress) @@ -79,13 +81,39 @@ abstract contract KSSmartIntentRouterAccounting is } approvalFlags >>= actionData.erc20Ids.length; - for (uint256 i = 0; i < actionData.erc721Ids.length; i++) { - address token = tokenData.erc721Data[actionData.erc721Ids[i]].token; - uint256 tokenId = tokenData.erc721Data[actionData.erc721Ids[i]].tokenId; + uint256 wildcardSeprator = _parseWildcardSeprator(actionData.erc721Ids); + uint256 wildcardCursor = wildcardSeprator + 1; + + for (uint256 i = 0; i < wildcardSeprator; i++) { + ERC721Data calldata erc721Data = tokenData.erc721Data[actionData.erc721Ids[i]]; + address token = erc721Data.token; + bool approvalFlag = _checkFlag(approvalFlags, i); + + if (erc721Data.tokenId != ERC721_WILDCARD_TOKEN_ID) { + ERC721DataLibrary.collect( + token, erc721Data.tokenId, mainAddress, actionContract, _forwarder, approvalFlag + ); + } else { + ERC721DataLibrary.collect( + token, + actionData.erc721Ids[wildcardCursor++], + mainAddress, + actionContract, + _forwarder, + approvalFlag + ); + } + } + } - ERC721DataLibrary.collect( - token, tokenId, mainAddress, actionContract, _forwarder, _checkFlag(approvalFlags, i) - ); + function _parseWildcardSeprator(uint256[] calldata erc721Ids) + internal + pure + returns (uint256 wildcardSeprator) + { + wildcardSeprator = erc721Ids.length; + for (uint256 i = 0; i < erc721Ids.length; i++) { + if (erc721Ids[i] == ERC721_IDS_SEPARATOR) return i; } } diff --git a/src/hooks/base/BaseTickBasedZapMigrateHook.sol b/src/hooks/base/BaseTickBasedZapMigrateHook.sol new file mode 100644 index 0000000..db06a9b --- /dev/null +++ b/src/hooks/base/BaseTickBasedZapMigrateHook.sol @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import {IKSSmartIntentHook} from '../../interfaces/hooks/IKSSmartIntentHook.sol'; +import {BaseStatefulHook} from '../base/BaseStatefulHook.sol'; + +import {TokenHelper} from 'ks-common-sc/src/libraries/token/TokenHelper.sol'; + +import {IERC721} from 'openzeppelin-contracts/contracts/token/ERC721/IERC721.sol'; + +import {ActionData} from '../../types/ActionData.sol'; +import {IntentData} from '../../types/IntentData.sol'; + +import {FixedPoint96} from '../../libraries/uniswapv4/FixedPoint96.sol'; + +import {Math} from 'openzeppelin-contracts/contracts/utils/math/Math.sol'; + +abstract contract BaseTickBasedZapMigrateHook is BaseStatefulHook { + using TokenHelper for address; + using Math for uint256; + + event ZapMigrated(address nftAddress, uint256 oldNftId, uint256 newNftId); + + error InvalidOwner(); + error ExceedMaxFeesPercent(); + error InvalidERC721Data(); + error InsufficientPositionValue(); + error TooLargeDistanceFromTickBoundaries(); + error TooSmallDistanceFromTickBoundaries(); + error InvalidPoolUniqueId(); + error TooSmallTickRangeLength(); + error TooLargeTickRangeLength(); + error ExceedMaxValueReductionPerAction(); + error WrongPriceMovementDirection(); + + /** + * @notice Data structure for zap migrate validation + * @param nftId The NFT ID + * @param minValueInToken0 The min value of the position in token0 + * @param minValueInToken1 The min value of the position in token1 + * @param maxValueReductionPerAction The max value reduction per action (1e6 = 100%) + * @param maxDistanceFromLowerTickBeforeMigration The max distance from the lower tick to the current tick before migration + * @param maxDistanceFromUpperTickBeforeMigration The max distance from the upper tick to the current tick before migration + * @param minDistanceFromLowerTickAfterMigration The min distance from the lower tick to the current tick after migration + * @param minDistanceFromUpperTickAfterMigration The min distance from the upper tick to the current tick after migration + * @param maxFee0 The max fee for output token0 (1e6 = 100%) + * @param maxFee1 The max fee for output token1 (1e6 = 100%) + */ + struct ZapMigrateHookData { + uint256 nftId; + uint256 minValueInToken0; + uint256 minValueInToken1; + uint256 maxValueReductionPerAction; + int24 maxDistanceFromLowerTickBeforeMigration; + int24 maxDistanceFromUpperTickBeforeMigration; + int24 minDistanceFromLowerTickAfterMigration; + int24 minDistanceFromUpperTickAfterMigration; + int24 minTickRangeLength; + int24 maxTickRangeLength; + uint256 maxFee0; + uint256 maxFee1; + } + + /** + * @notice Data structure for before execution data + * @param originalNftId The original NFT ID + * @param poolUniqueId The unique ID of the pool + * @param amount0Before The amount of token0 of the position before execution + * @param amount1Before The amount of token1 of the position before execution + * @param balance0Before The balance of token0 of the router before execution + * @param balance1Before The balance of token1 of the router before execution + * @param directionalPositionValue The directional position value before execution + * @param direction The direction which the price changes + * @param additionalData Additional data for the before execution + */ + struct BeforeExecutionData { + uint256 originalNftId; + bytes32 poolUniqueId; + uint256 amount0Before; + uint256 amount1Before; + uint256 balance0Before; + uint256 balance1Before; + uint256 directionalPositionValue; + bool direction; + uint160 sqrtPriceX96Before; + bytes additionalData; + } + + /** + * @notice Data structure for pool and position info + * @param poolUniqueId The unique ID of the pool + * @param sqrtPriceX96 The sqrt price of the pool + * @param tick The current tick of the pool + * @param tickLower The lower tick of the position + * @param tickUpper The upper tick of the position + * @param token0 The token0 of the pool + * @param token1 The token1 of the pool + * @param amount0 The amount of token0 of the position + * @param amount1 The amount of token1 of the position + */ + struct PoolAndPositionInfo { + bytes32 poolUniqueId; + uint160 sqrtPriceX96; + int24 tick; + int24 tickLower; + int24 tickUpper; + address token0; + address token1; + uint256 amount0; + uint256 amount1; + } + + uint256 internal constant FEE_PRECISION = 1_000_000; + + mapping(bytes32 intentHash => uint256) public nftIds; + + modifier checkTokenLengths(ActionData calldata actionData) override { + require(actionData.erc20Ids.length == 0, InvalidTokenData()); + require(actionData.erc721Ids.length == 1, InvalidTokenData()); + _; + } + + /// @inheritdoc IKSSmartIntentHook + function beforeExecution( + bytes32 intentHash, + IntentData calldata intentData, + ActionData calldata actionData + ) + external + view + override + checkTokenLengths(actionData) + onlyWhitelistedRouter + returns (uint256[] memory, bytes memory beforeExecutionData) + { + ZapMigrateHookData calldata hookIntentData = _decodeHookData(intentData.coreData.hookIntentData); + address nftAddress = intentData.tokenData.erc721Data[0].token; + + uint256 currentNftId = nftIds[intentHash]; + if (currentNftId == 0) { + currentNftId = hookIntentData.nftId; + } + + PoolAndPositionInfo memory ppInfo = _getPoolAndPositionInfo(nftAddress, currentNftId); + + uint256 valueInToken0 = + ppInfo.amount0 + _convertToken1ToToken0(ppInfo.sqrtPriceX96, ppInfo.amount1); + uint256 valueInToken1 = + ppInfo.amount1 + _convertToken0ToToken1(ppInfo.sqrtPriceX96, ppInfo.amount0); + + uint256 directionalPositionValue; + bool direction; + if (ppInfo.tick - ppInfo.tickLower <= hookIntentData.maxDistanceFromLowerTickBeforeMigration) { + direction = true; + directionalPositionValue = valueInToken1; + } else if ( + ppInfo.tickUpper - ppInfo.tick <= hookIntentData.maxDistanceFromUpperTickBeforeMigration + ) { + direction = false; + directionalPositionValue = valueInToken0; + } else { + revert TooLargeDistanceFromTickBoundaries(); + } + + beforeExecutionData = abi.encode( + BeforeExecutionData({ + originalNftId: currentNftId, + poolUniqueId: ppInfo.poolUniqueId, + amount0Before: ppInfo.amount0, + amount1Before: ppInfo.amount1, + balance0Before: ppInfo.token0.balanceOf(msg.sender), + balance1Before: ppInfo.token1.balanceOf(msg.sender), + sqrtPriceX96Before: ppInfo.sqrtPriceX96, + directionalPositionValue: directionalPositionValue, + direction: direction, + additionalData: _getAdditionalData(nftAddress) + }) + ); + } + + /// @inheritdoc IKSSmartIntentHook + function afterExecution( + bytes32 intentHash, + IntentData calldata intentData, + bytes calldata _beforeExecutionData, + bytes calldata + ) + external + override + onlyWhitelistedRouter + returns ( + address[] memory tokens, + uint256[] memory fees, + uint256[] memory amounts, + address recipient + ) + { + if (_beforeExecutionData.length == 0) { + return (new address[](0), new uint256[](0), new uint256[](0), address(0)); + } + + ZapMigrateHookData memory hookIntentData = _decodeHookData(intentData.coreData.hookIntentData); + BeforeExecutionData memory beforeExecutionData = + abi.decode(_beforeExecutionData, (BeforeExecutionData)); + address nftAddress = intentData.tokenData.erc721Data[0].token; + + uint256 newNftId = _getNewNftId(nftAddress, beforeExecutionData.additionalData); + PoolAndPositionInfo memory ppInfo = _getPoolAndPositionInfo(nftAddress, newNftId); + if (ppInfo.poolUniqueId != beforeExecutionData.poolUniqueId) { + revert InvalidPoolUniqueId(); + } + + // check owner + if ( + IERC721(nftAddress).ownerOf(beforeExecutionData.originalNftId) + != intentData.coreData.mainAddress + ) { + revert InvalidOwner(); + } + if (IERC721(nftAddress).ownerOf(newNftId) != intentData.coreData.mainAddress) { + revert InvalidOwner(); + } + + tokens = new address[](2); + tokens[0] = ppInfo.token0; + tokens[1] = ppInfo.token1; + fees = new uint256[](2); + fees[0] = ppInfo.token0.balanceOf(msg.sender) - beforeExecutionData.balance0Before; + fees[1] = ppInfo.token1.balanceOf(msg.sender) - beforeExecutionData.balance1Before; + amounts = new uint256[](2); + + // check max fees + if (fees[0] * FEE_PRECISION > beforeExecutionData.amount0Before * hookIntentData.maxFee0) { + revert ExceedMaxFeesPercent(); + } + if (fees[1] * FEE_PRECISION > beforeExecutionData.amount1Before * hookIntentData.maxFee1) { + revert ExceedMaxFeesPercent(); + } + + // check tick boundaries + if (ppInfo.tick - ppInfo.tickLower < hookIntentData.minDistanceFromLowerTickAfterMigration) { + revert TooSmallDistanceFromTickBoundaries(); + } + if (ppInfo.tickUpper - ppInfo.tick < hookIntentData.minDistanceFromUpperTickAfterMigration) { + revert TooSmallDistanceFromTickBoundaries(); + } + if (ppInfo.tickUpper - ppInfo.tickLower < hookIntentData.minTickRangeLength) { + revert TooSmallTickRangeLength(); + } + if (ppInfo.tickUpper - ppInfo.tickLower > hookIntentData.maxTickRangeLength) { + revert TooLargeTickRangeLength(); + } + + uint256 valueInToken0 = + ppInfo.amount0 + _convertToken1ToToken0(ppInfo.sqrtPriceX96, ppInfo.amount1); + if (valueInToken0 < hookIntentData.minValueInToken0) { + revert InsufficientPositionValue(); + } + uint256 valueInToken1 = + ppInfo.amount1 + _convertToken0ToToken1(ppInfo.sqrtPriceX96, ppInfo.amount0); + if (valueInToken1 < hookIntentData.minValueInToken1) { + revert InsufficientPositionValue(); + } + + require( + (ppInfo.sqrtPriceX96 <= beforeExecutionData.sqrtPriceX96Before) + == beforeExecutionData.direction, + WrongPriceMovementDirection() + ); + + uint256 directionalPositionValueAfter = + beforeExecutionData.direction ? valueInToken1 : valueInToken0; + + // check max value reduction per action (ppm, 1e6 = 100%) + uint256 maxValueReductionPerAction = hookIntentData.maxValueReductionPerAction; + if (maxValueReductionPerAction > FEE_PRECISION) { + maxValueReductionPerAction = FEE_PRECISION; + } + uint256 minDirectionalPositionValueAfter = beforeExecutionData.directionalPositionValue + .mulDiv(FEE_PRECISION - maxValueReductionPerAction, FEE_PRECISION); + if (directionalPositionValueAfter < minDirectionalPositionValueAfter) { + revert ExceedMaxValueReductionPerAction(); + } + + // record new NFT ID + nftIds[intentHash] = newNftId; + } + + function _decodeHookData(bytes calldata data) + internal + pure + returns (ZapMigrateHookData calldata hookData) + { + assembly ('memory-safe') { + hookData := data.offset + } + } + + function _getPoolAndPositionInfo(address nftAddress, uint256 nftId) + internal + view + virtual + returns (PoolAndPositionInfo memory ppInfo); + + function _getAdditionalData(address nftAddress) + internal + view + virtual + returns (bytes memory additionalData) + {} + + function _getNewNftId(address nftAddress, bytes memory additionalData) + internal + view + virtual + returns (uint256 newNftId); + + function _convertToken1ToToken0(uint256 sqrtPriceX96, uint256 amount1) + internal + pure + virtual + returns (uint256 amount0) + { + return amount1.mulDiv(FixedPoint96.Q96, sqrtPriceX96).mulDiv(FixedPoint96.Q96, sqrtPriceX96); + } + + function _convertToken0ToToken1(uint256 sqrtPriceX96, uint256 amount0) + internal + pure + virtual + returns (uint256 amount1) + { + return amount0.mulDiv(sqrtPriceX96, FixedPoint96.Q96).mulDiv(sqrtPriceX96, FixedPoint96.Q96); + } +} diff --git a/src/hooks/zap-migrate/KSZapMigratePancakeV4CLHook.sol b/src/hooks/zap-migrate/KSZapMigratePancakeV4CLHook.sol new file mode 100644 index 0000000..37193cf --- /dev/null +++ b/src/hooks/zap-migrate/KSZapMigratePancakeV4CLHook.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import {BaseStatefulHook} from '../base/BaseStatefulHook.sol'; +import {BaseTickBasedZapMigrateHook} from '../base/BaseTickBasedZapMigrateHook.sol'; + +import {ActionData} from '../../types/ActionData.sol'; +import {IntentData} from '../../types/IntentData.sol'; + +import {ICLPoolManager} from '../../interfaces/pancakev4/ICLPoolManager.sol'; +import {ICLPositionManager} from '../../interfaces/pancakev4/ICLPositionManager.sol'; +import {PoolId, PoolKey, TickInfo} from '../../interfaces/pancakev4/Types.sol'; + +import {FixedPoint128} from '../../libraries/uniswapv4/FixedPoint128.sol'; +import {LiquidityAmounts} from '../../libraries/uniswapv4/LiquidityAmounts.sol'; +import {TickMath} from '../../libraries/uniswapv4/TickMath.sol'; + +import {Math} from 'openzeppelin-contracts/contracts/utils/math/Math.sol'; + +contract KSZapMigrateUniswapV3Hook is BaseTickBasedZapMigrateHook { + constructor(address[] memory initialRouters) BaseStatefulHook(initialRouters) {} + + function _getPoolAndPositionInfo(address nftAddress, uint256 nftId) + internal + view + override + returns (PoolAndPositionInfo memory ppInfo) + { + PoolKey memory poolKey; + uint256 feeGrowthInside0Last; + uint256 feeGrowthInside1Last; + uint128 positionLiquidity; + ( + poolKey, + ppInfo.tickLower, + ppInfo.tickUpper, + positionLiquidity, + feeGrowthInside0Last, + feeGrowthInside1Last, + ) = ICLPositionManager(nftAddress).positions(nftId); + + PoolId poolId = _toId(poolKey); + ppInfo.poolUniqueId = PoolId.unwrap(poolId); + + ppInfo.token0 = poolKey.currency0; + ppInfo.token1 = poolKey.currency1; + (ppInfo.sqrtPriceX96, ppInfo.tick,,) = ICLPoolManager(poolKey.poolManager).getSlot0(poolId); + + (uint256 feeGrowthInside0, uint256 feeGrowthInside1) = _getFeeGrowthInside( + ICLPoolManager(poolKey.poolManager), poolId, ppInfo.tickLower, ppInfo.tick, ppInfo.tickUpper + ); + + (ppInfo.amount0, ppInfo.amount1) = LiquidityAmounts.getAmountsForLiquidity( + ppInfo.sqrtPriceX96, + TickMath.getSqrtRatioAtTick(ppInfo.tickLower), + TickMath.getSqrtRatioAtTick(ppInfo.tickUpper), + positionLiquidity + ); + + unchecked { + ppInfo.amount0 += Math.mulDiv( + feeGrowthInside0 - feeGrowthInside0Last, positionLiquidity, FixedPoint128.Q128 + ); + ppInfo.amount1 += Math.mulDiv( + feeGrowthInside1 - feeGrowthInside1Last, positionLiquidity, FixedPoint128.Q128 + ); + } + } + + function _getFeeGrowthInside( + ICLPoolManager clPoolManager, + PoolId poolId, + int24 tickLower, + int24 tickCurrent, + int24 tickUpper + ) internal view returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) { + (uint256 feeGrowthGlobal0X128, uint256 feeGrowthGlobal1X128) = + clPoolManager.getFeeGrowthGlobals(poolId); + + TickInfo memory lower = clPoolManager.getPoolTickInfo(poolId, tickLower); + TickInfo memory upper = clPoolManager.getPoolTickInfo(poolId, tickUpper); + + uint256 feeGrowthBelow0X128; + uint256 feeGrowthBelow1X128; + unchecked { + if (tickCurrent >= tickLower) { + feeGrowthBelow0X128 = lower.feeGrowthOutside0X128; + feeGrowthBelow1X128 = lower.feeGrowthOutside1X128; + } else { + feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128; + feeGrowthBelow1X128 = feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128; + } + + uint256 feeGrowthAbove0X128; + uint256 feeGrowthAbove1X128; + if (tickCurrent < tickUpper) { + feeGrowthAbove0X128 = upper.feeGrowthOutside0X128; + feeGrowthAbove1X128 = upper.feeGrowthOutside1X128; + } else { + feeGrowthAbove0X128 = feeGrowthGlobal0X128 - upper.feeGrowthOutside0X128; + feeGrowthAbove1X128 = feeGrowthGlobal1X128 - upper.feeGrowthOutside1X128; + } + + feeGrowthInside0X128 = feeGrowthGlobal0X128 - feeGrowthBelow0X128 - feeGrowthAbove0X128; + feeGrowthInside1X128 = feeGrowthGlobal1X128 - feeGrowthBelow1X128 - feeGrowthAbove1X128; + } + } + + function _getAdditionalData(address nftAddress) + internal + view + override + returns (bytes memory additionalData) + {} + + function _getNewNftId(address nftAddress, bytes memory) + internal + view + override + returns (uint256 newNftId) + { + return ICLPositionManager(nftAddress).nextTokenId() - 1; + } + + function _toId(PoolKey memory poolKey) internal pure returns (PoolId poolId) { + assembly ('memory-safe') { + poolId := keccak256(poolKey, 0xc0) + } + } +} diff --git a/src/hooks/zap-migrate/KSZapMigrateUniswapV3Hook.sol b/src/hooks/zap-migrate/KSZapMigrateUniswapV3Hook.sol new file mode 100644 index 0000000..4e7ff9e --- /dev/null +++ b/src/hooks/zap-migrate/KSZapMigrateUniswapV3Hook.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import {BaseStatefulHook} from '../base/BaseStatefulHook.sol'; +import {BaseTickBasedZapMigrateHook} from '../base/BaseTickBasedZapMigrateHook.sol'; + +import {ActionData} from '../../types/ActionData.sol'; +import {IntentData} from '../../types/IntentData.sol'; + +import {IUniswapV3Factory} from '../../interfaces/uniswapv3/IUniswapV3Factory.sol'; +import {IUniswapV3PM} from '../../interfaces/uniswapv3/IUniswapV3PM.sol'; +import {IUniswapV3Pool} from '../../interfaces/uniswapv3/IUniswapV3Pool.sol'; + +import {FixedPoint128} from '../../libraries/uniswapv4/FixedPoint128.sol'; +import {LiquidityAmounts} from '../../libraries/uniswapv4/LiquidityAmounts.sol'; +import {TickMath} from '../../libraries/uniswapv4/TickMath.sol'; + +import {Math} from 'openzeppelin-contracts/contracts/utils/math/Math.sol'; + +contract KSZapMigrateUniswapV3Hook is BaseTickBasedZapMigrateHook { + constructor(address[] memory initialRouters) BaseStatefulHook(initialRouters) {} + + function _getPoolAndPositionInfo(address nftAddress, uint256 nftId) + internal + view + override + returns (PoolAndPositionInfo memory ppInfo) + { + uint24 fee; + uint128 liquidity; + uint256 feeGrowthInside0LastX128; + uint256 feeGrowthInside1LastX128; + ( + ,, + ppInfo.token0, + ppInfo.token1, + fee, + ppInfo.tickLower, + ppInfo.tickUpper, + liquidity, + feeGrowthInside0LastX128, + feeGrowthInside1LastX128,, + ) = IUniswapV3PM(nftAddress).positions(nftId); + + IUniswapV3Pool pool = IUniswapV3Pool( + IUniswapV3Factory(IUniswapV3PM(nftAddress).factory()) + .getPool(ppInfo.token0, ppInfo.token1, fee) + ); + ppInfo.poolUniqueId = bytes32(uint256(uint160(address(pool)))); + + (ppInfo.sqrtPriceX96, ppInfo.tick,,,,,) = pool.slot0(); + + (ppInfo.amount0, ppInfo.amount1) = LiquidityAmounts.getAmountsForLiquidity( + ppInfo.sqrtPriceX96, + TickMath.getSqrtRatioAtTick(ppInfo.tickLower), + TickMath.getSqrtRatioAtTick(ppInfo.tickUpper), + liquidity + ); + + (uint256 feeGrowthInside0, uint256 feeGrowthInside1) = + _getFeeGrowthInside(pool, ppInfo.tickLower, ppInfo.tick, ppInfo.tickUpper); + + unchecked { + ppInfo.amount0 += Math.mulDiv( + feeGrowthInside0 - feeGrowthInside0LastX128, liquidity, FixedPoint128.Q128 + ); + ppInfo.amount1 += Math.mulDiv( + feeGrowthInside1 - feeGrowthInside1LastX128, liquidity, FixedPoint128.Q128 + ); + } + } + + function _getFeeGrowthInside( + IUniswapV3Pool pool, + int24 tickLower, + int24 tickCurrent, + int24 tickUpper + ) internal view returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) { + (uint256 feeGrowthGlobal0X128, uint256 feeGrowthGlobal1X128) = + (pool.feeGrowthGlobal0X128(), pool.feeGrowthGlobal1X128()); + (,, uint256 feeGrowthOutside0X128Lower, uint256 feeGrowthOutside1X128Lower,,,,) = + pool.ticks(tickLower); + (,, uint256 feeGrowthOutside0X128Upper, uint256 feeGrowthOutside1X128Upper,,,,) = + pool.ticks(tickUpper); + + uint256 feeGrowthBelow0X128; + uint256 feeGrowthBelow1X128; + unchecked { + if (tickCurrent >= tickLower) { + feeGrowthBelow0X128 = feeGrowthOutside0X128Lower; + feeGrowthBelow1X128 = feeGrowthOutside1X128Lower; + } else { + feeGrowthBelow0X128 = feeGrowthGlobal0X128 - feeGrowthOutside0X128Lower; + feeGrowthBelow1X128 = feeGrowthGlobal1X128 - feeGrowthOutside1X128Lower; + } + + uint256 feeGrowthAbove0X128; + uint256 feeGrowthAbove1X128; + if (tickCurrent < tickUpper) { + feeGrowthAbove0X128 = feeGrowthOutside0X128Upper; + feeGrowthAbove1X128 = feeGrowthOutside1X128Upper; + } else { + feeGrowthAbove0X128 = feeGrowthGlobal0X128 - feeGrowthOutside0X128Upper; + feeGrowthAbove1X128 = feeGrowthGlobal1X128 - feeGrowthOutside1X128Upper; + } + + feeGrowthInside0X128 = feeGrowthGlobal0X128 - feeGrowthBelow0X128 - feeGrowthAbove0X128; + feeGrowthInside1X128 = feeGrowthGlobal1X128 - feeGrowthBelow1X128 - feeGrowthAbove1X128; + } + } + + function _getNewNftId(address nftAddress, bytes memory additionalData) + internal + view + override + returns (uint256 newNftId) + { + uint256 index = abi.decode(additionalData, (uint256)); + return IUniswapV3PM(nftAddress).tokenByIndex(index); + } + + function _getAdditionalData(address nftAddress) + internal + view + override + returns (bytes memory additionalData) + { + // Cache the pre-action total supply so we can read the first newly-minted NFT by index. + additionalData = abi.encode(IUniswapV3PM(nftAddress).totalSupply()); + } +} diff --git a/src/hooks/zap-migrate/KSZapMigrateUniswapV4Hook.sol b/src/hooks/zap-migrate/KSZapMigrateUniswapV4Hook.sol new file mode 100644 index 0000000..31b2e73 --- /dev/null +++ b/src/hooks/zap-migrate/KSZapMigrateUniswapV4Hook.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import {BaseStatefulHook} from '../base/BaseStatefulHook.sol'; +import {BaseTickBasedZapMigrateHook} from '../base/BaseTickBasedZapMigrateHook.sol'; + +import {ActionData} from '../../types/ActionData.sol'; +import {IntentData} from '../../types/IntentData.sol'; + +import {IPoolManager} from '../../interfaces/uniswapv4/IPoolManager.sol'; +import {IPositionManager} from '../../interfaces/uniswapv4/IPositionManager.sol'; +import {PoolKey} from '../../interfaces/uniswapv4/Types.sol'; + +import {FixedPoint128} from '../../libraries/uniswapv4/FixedPoint128.sol'; +import {LiquidityAmounts} from '../../libraries/uniswapv4/LiquidityAmounts.sol'; +import {StateLibrary} from '../../libraries/uniswapv4/StateLibrary.sol'; +import {TickMath} from '../../libraries/uniswapv4/TickMath.sol'; + +import {Math} from 'openzeppelin-contracts/contracts/utils/math/Math.sol'; + +contract KSZapMigrateUniswapV3Hook is BaseTickBasedZapMigrateHook { + using StateLibrary for IPoolManager; + + constructor(address[] memory initialRouters) BaseStatefulHook(initialRouters) {} + + function _getPoolAndPositionInfo(address nftAddress, uint256 nftId) + internal + view + override + returns (PoolAndPositionInfo memory ppInfo) + { + (PoolKey memory poolKey, uint256 positionInfo) = + IPositionManager(nftAddress).getPoolAndPositionInfo(nftId); + bytes32 poolId = StateLibrary.getPoolId(poolKey); + + IPoolManager poolManager = IPositionManager(nftAddress).poolManager(); + + ppInfo.token0 = poolKey.currency0; + ppInfo.token1 = poolKey.currency1; + (ppInfo.sqrtPriceX96, ppInfo.tick,,) = poolManager.getSlot0(poolId); + (ppInfo.tickLower, ppInfo.tickUpper) = StateLibrary.getTickRange(positionInfo); + + bytes32 positionKey = StateLibrary.calculatePositionKey( + nftAddress, ppInfo.tickLower, ppInfo.tickUpper, bytes32(nftId) + ); + (uint128 positionLiquidity, uint256 feeGrowthInside0Last, uint256 feeGrowthInside1Last) = + poolManager.getPositionInfo(poolId, positionKey); + (uint256 feeGrowthInside0, uint256 feeGrowthInside1) = + poolManager.getFeeGrowthInside(poolId, ppInfo.tickLower, ppInfo.tickUpper, ppInfo.tick); + + (ppInfo.amount0, ppInfo.amount1) = LiquidityAmounts.getAmountsForLiquidity( + ppInfo.sqrtPriceX96, + TickMath.getSqrtRatioAtTick(ppInfo.tickLower), + TickMath.getSqrtRatioAtTick(ppInfo.tickUpper), + positionLiquidity + ); + + unchecked { + ppInfo.amount0 += Math.mulDiv( + feeGrowthInside0 - feeGrowthInside0Last, positionLiquidity, FixedPoint128.Q128 + ); + ppInfo.amount1 += Math.mulDiv( + feeGrowthInside1 - feeGrowthInside1Last, positionLiquidity, FixedPoint128.Q128 + ); + } + } + + function _getNewNftId(address nftAddress, bytes memory) + internal + view + override + returns (uint256 newNftId) + { + return IPositionManager(nftAddress).nextTokenId() - 1; + } +} diff --git a/src/interfaces/pancakev4/IVault.sol b/src/interfaces/pancakev4/IVault.sol new file mode 100644 index 0000000..3c1ba25 --- /dev/null +++ b/src/interfaces/pancakev4/IVault.sol @@ -0,0 +1,130 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {BalanceDelta} from './Types.sol'; + +interface IVault { + event AppRegistered(address indexed app); + + /// @notice Thrown when a app is not registered + error AppUnregistered(); + + /// @notice Thrown when a currency is not netted out after a lock + error CurrencyNotSettled(); + + /// @notice Thrown when there is already a locker + /// @param locker The address of the current locker + error LockerAlreadySet(address locker); + + /// @notice Thrown when passing in msg.value for non-native currency + error SettleNonNativeCurrencyWithValue(); + + /// @notice Thrown when `clear` is called with an amount that is not exactly equal to the open currency delta. + error MustClearExactPositiveDelta(); + + /// @notice Thrown when there is no locker + error NoLocker(); + + /// @notice Thrown when collectFee is attempted on a token that is synced. + error FeeCurrencySynced(); + + function isAppRegistered(address app) external returns (bool); + + /// @notice Returns the reserves for a a given pool type and currency + function reservesOfApp(address app, address currency) external view returns (uint256); + + /// @notice register an app so that it can perform accounting base on vault + function registerApp(address app) external; + + /// @notice Returns the locker who is locking the vault + function getLocker() external view returns (address locker); + + /// @notice Returns the reserve and its amount that is currently being stored in trnasient storage + function getVaultReserve() external view returns (address, uint256); + + /// @notice Returns lock data + function getUnsettledDeltasCount() external view returns (uint256 count); + + /// @notice Get the current delta for a locker in the given currency + /// @param currency The currency for which to lookup the delta + function currencyDelta(address settler, address currency) external view returns (int256); + + /// @notice All operations go through this function + /// @param data Any data to pass to the callback, via `ILockCallback(msg.sender).lockCallback(data)` + /// @return The data returned by the call to `ILockCallback(msg.sender).lockCallback(data)` + function lock(bytes calldata data) external returns (bytes memory); + + /// @notice Called by registered app to account for a change in the pool balance, + /// convenient for AMM pool manager, typically after modifyLiquidity, swap, donate, + /// include the case where hookDelta is involved + /// @param currency0 The PoolKey currency0 to update + /// @param currency1 The PoolKey currency1 to update + /// @param delta The change in the pool's balance + /// @param settler The address whose delta will be updated + /// @param hookDelta The change in the pool's balance from hook + /// @param hook The address whose hookDelta will be updated + function accountAppBalanceDelta( + address currency0, + address currency1, + BalanceDelta delta, + address settler, + BalanceDelta hookDelta, + address hook + ) external; + + /// @notice Called by registered app to account for a change in the pool balance, + /// convenient for AMM pool manager, typically after modifyLiquidity, swap, donate + /// @param currency0 The PoolKey currency0 to update + /// @param currency1 The PoolKey currency1 to update + /// @param delta The change in the pool's balance + /// @param settler The address whose delta will be updated + function accountAppBalanceDelta( + address currency0, + address currency1, + BalanceDelta delta, + address settler + ) external; + + /// @notice This works as a general accounting mechanism for non-dex app + /// @param currency The currency to update + /// @param delta The change in the balance + /// @param settler The address whose delta will be updated + function accountAppBalanceDelta(address currency, int128 delta, address settler) external; + + /// @notice Called by the user to net out some value owed to the user + /// @dev Will revert if the requested amount is not available, consider using `mint` instead + /// @dev Can also be used as a mechanism for free flash loans + function take(address currency, address to, uint256 amount) external; + + /// @notice Writes the current ERC20 balance of the specified currency to transient storage + /// This is used to checkpoint balances for the manager and derive deltas for the caller. + /// @dev This MUST be called before any ERC20 tokens are sent into the contract, but can be skipped + /// for native tokens because the amount to settle is determined by the sent value. + /// However, if an ERC20 token has been synced and not settled, and the caller instead wants to settle + /// native funds, this function can be called with the native currency to then be able to settle the native currency + function sync(address token0) external; + + /// @notice Called by the user to pay what is owed + function settle() external payable returns (uint256 paid); + + /// @notice Called by the user to pay on behalf of another address + /// @param recipient The address to credit for the payment + /// @return paid The amount of currency settled + function settleFor(address recipient) external payable returns (uint256 paid); + + /// @notice WARNING - Any currency that is cleared, will be non-retreivable, and locked in the contract permanently. + /// A call to clear will zero out a positive balance WITHOUT a corresponding transfer. + /// @dev This could be used to clear a balance that is considered dust. + /// Additionally, the amount must be the exact positive balance. This is to enforce that the caller is aware of the amount being cleared. + function clear(address currency, uint256 amount) external; + + /// @notice Called by app to collect any fee related + /// @dev no restriction on caller, underflow happen if caller collect more than the reserve + function collectFee(address currency, uint256 amount, address recipient) external; + + /// @notice Called by the user to store surplus tokens in the vault + function mint(address to, address currency, uint256 amount) external; + + /// @notice Called by the user to use surplus tokens for payment settlement + function burn(address from, address currency, uint256 amount) external; +} diff --git a/src/interfaces/uniswapv3/IUniswapV3Factory.sol b/src/interfaces/uniswapv3/IUniswapV3Factory.sol new file mode 100644 index 0000000..c76eb61 --- /dev/null +++ b/src/interfaces/uniswapv3/IUniswapV3Factory.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +/// @title The interface for the Uniswap V3 Factory +/// @notice The Uniswap V3 Factory facilitates creation of Uniswap V3 pools and control over the protocol fees +interface IUniswapV3Factory { + /// @notice Emitted when the owner of the factory is changed + /// @param oldOwner The owner before the owner was changed + /// @param newOwner The owner after the owner was changed + event OwnerChanged(address indexed oldOwner, address indexed newOwner); + + /// @notice Emitted when a pool is created + /// @param token0 The first token of the pool by address sort order + /// @param token1 The second token of the pool by address sort order + /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip + /// @param tickSpacing The minimum number of ticks between initialized ticks + /// @param pool The address of the created pool + event PoolCreated( + address indexed token0, + address indexed token1, + uint24 indexed fee, + int24 tickSpacing, + address pool + ); + + /// @notice Emitted when a new fee amount is enabled for pool creation via the factory + /// @param fee The enabled fee, denominated in hundredths of a bip + /// @param tickSpacing The minimum number of ticks between initialized ticks for pools created with the given fee + event FeeAmountEnabled(uint24 indexed fee, int24 indexed tickSpacing); + + /// @notice Returns the current owner of the factory + /// @dev Can be changed by the current owner via setOwner + /// @return The address of the factory owner + function owner() external view returns (address); + + /// @notice Returns the tick spacing for a given fee amount, if enabled, or 0 if not enabled + /// @dev A fee amount can never be removed, so this value should be hard coded or cached in the calling context + /// @param fee The enabled fee, denominated in hundredths of a bip. Returns 0 in case of unenabled fee + /// @return The tick spacing + function feeAmountTickSpacing(uint24 fee) external view returns (int24); + + /// @notice Returns the pool address for a given pair of tokens and a fee, or address 0 if it does not exist + /// @dev tokenA and tokenB may be passed in either token0/token1 or token1/token0 order + /// @param tokenA The contract address of either token0 or token1 + /// @param tokenB The contract address of the other token + /// @param fee The fee collected upon every swap in the pool, denominated in hundredths of a bip + /// @return pool The pool address + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool); + + /// @notice Creates a pool for the given two tokens and fee + /// @param tokenA One of the two tokens in the desired pool + /// @param tokenB The other of the two tokens in the desired pool + /// @param fee The desired fee for the pool + /// @dev tokenA and tokenB may be passed in either order: token0/token1 or token1/token0. tickSpacing is retrieved + /// from the fee. The call will revert if the pool already exists, the fee is invalid, or the token arguments + /// are invalid. + /// @return pool The address of the newly created pool + function createPool(address tokenA, address tokenB, uint24 fee) external returns (address pool); + + /// @notice Updates the owner of the factory + /// @dev Must be called by the current owner + /// @param _owner The new owner of the factory + function setOwner(address _owner) external; + + /// @notice Enables a fee amount with the given tickSpacing + /// @dev Fee amounts may never be removed once enabled + /// @param fee The fee amount to enable, denominated in hundredths of a bip (i.e. 1e-6) + /// @param tickSpacing The spacing between ticks to be enforced for all pools created with the given fee amount + function enableFeeAmount(uint24 fee, int24 tickSpacing) external; +} diff --git a/src/interfaces/uniswapv3/IUniswapV3PM.sol b/src/interfaces/uniswapv3/IUniswapV3PM.sol index 51f0445..db9fa88 100644 --- a/src/interfaces/uniswapv3/IUniswapV3PM.sol +++ b/src/interfaces/uniswapv3/IUniswapV3PM.sol @@ -1,9 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; -import {IERC721} from 'openzeppelin-contracts/contracts/token/ERC721/IERC721.sol'; +import { + IERC721Enumerable +} from 'openzeppelin-contracts/contracts/token/ERC721/extensions/IERC721Enumerable.sol'; -interface IUniswapV3PM is IERC721 { +interface IUniswapV3PM is IERC721Enumerable { function multicall(bytes[] calldata data) external payable returns (bytes[] memory results); /// @notice Unwraps the contract's WETH9 balance and sends it to recipient as ETH. @@ -78,4 +80,23 @@ interface IUniswapV3PM is IERC721 { returns (uint256 amount0, uint256 amount1); function factory() external view returns (address); + + struct MintParams { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + uint256 amount0Desired; + uint256 amount1Desired; + uint256 amount0Min; + uint256 amount1Min; + address recipient; + uint256 deadline; + } + + function mint(MintParams calldata params) + external + payable + returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); } diff --git a/src/interfaces/uniswapv3/IUniswapV3Pool.sol b/src/interfaces/uniswapv3/IUniswapV3Pool.sol index b844b7f..921d318 100644 --- a/src/interfaces/uniswapv3/IUniswapV3Pool.sol +++ b/src/interfaces/uniswapv3/IUniswapV3Pool.sol @@ -58,4 +58,22 @@ interface IUniswapV3Pool { uint32 secondsOutside, bool initialized ); + + /// @notice Swap token0 for token1, or token1 for token0 + /// @dev The caller of this method receives a callback in the form of IUniswapV3SwapCallback#uniswapV3SwapCallback + /// @param recipient The address to receive the output of the swap + /// @param zeroForOne The direction of the swap, true for token0 to token1, false for token1 to token0 + /// @param amountSpecified The amount of the swap, which implicitly configures the swap as exact input (positive), or exact output (negative) + /// @param sqrtPriceLimitX96 The Q64.96 sqrt price limit. If zero for one, the price cannot be less than this + /// value after the swap. If one for zero, the price cannot be greater than this value after the swap + /// @param data Any data to be passed through to the callback + /// @return amount0 The delta of the balance of token0 of the pool, exact when negative, minimum when positive + /// @return amount1 The delta of the balance of token1 of the pool, exact when negative, minimum when positive + function swap( + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 sqrtPriceLimitX96, + bytes calldata data + ) external returns (int256 amount0, int256 amount1); } diff --git a/src/interfaces/uniswapv4/IPoolManager.sol b/src/interfaces/uniswapv4/IPoolManager.sol index 97ff9a3..6edb6a3 100644 --- a/src/interfaces/uniswapv4/IPoolManager.sol +++ b/src/interfaces/uniswapv4/IPoolManager.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; +import {BalanceDelta, PoolKey, SwapParams} from './Types.sol'; + /// @notice Interface for the PoolManager interface IPoolManager { /// @notice Called by external contracts to access granular pool state @@ -32,6 +34,45 @@ interface IPoolManager { /// @return values List of loaded values function exttload(bytes32[] calldata slots) external view returns (bytes32[] memory values); + /// @notice All interactions on the contract that account deltas require unlocking. A caller that calls `unlock` must implement + /// `IUnlockCallback(msg.sender).unlockCallback(data)`, where they interact with the remaining functions on this contract. + /// @dev The only functions callable without an unlocking are `initialize` and `updateDynamicLPFee` + /// @param data Any data to pass to the callback, via `IUnlockCallback(msg.sender).unlockCallback(data)` + /// @return The data returned by the call to `IUnlockCallback(msg.sender).unlockCallback(data)` + function unlock(bytes calldata data) external returns (bytes memory); + + /// @notice Swap against the given pool + /// @param key The pool to swap in + /// @param params The parameters for swapping + /// @param hookData The data to pass through to the swap hooks + /// @return swapDelta The balance delta of the address swapping + /// @dev Swapping on low liquidity pools may cause unexpected swap amounts when liquidity available is less than amountSpecified. + /// Additionally note that if interacting with hooks that have the BEFORE_SWAP_RETURNS_DELTA_FLAG or AFTER_SWAP_RETURNS_DELTA_FLAG + /// the hook may alter the swap input/output. Integrators should perform checks on the returned swapDelta. + function swap(PoolKey memory key, SwapParams memory params, bytes calldata hookData) + external + returns (BalanceDelta swapDelta); + + /// @notice Writes the current ERC20 balance of the specified currency to transient storage + /// This is used to checkpoint balances for the manager and derive deltas for the caller. + /// @dev This MUST be called before any ERC20 tokens are sent into the contract, but can be skipped + /// for native tokens because the amount to settle is determined by the sent value. + /// However, if an ERC20 token has been synced and not settled, and the caller instead wants to settle + /// native funds, this function can be called with the native currency to then be able to settle the native currency + function sync(address currency) external; + + /// @notice Called by the user to net out some value owed to the user + /// @dev Will revert if the requested amount is not available, consider using `mint` instead + /// @dev Can also be used as a mechanism for free flash loans + /// @param currency The currency to withdraw from the pool manager + /// @param to The address to withdraw to + /// @param amount The amount of currency to withdraw + function take(address currency, address to, uint256 amount) external; + + /// @notice Called by the user to pay what is owed + /// @return paid The amount of currency settled + function settle() external payable returns (uint256 paid); + /// @notice Thrown when a currency is not netted out after the contract is unlocked error CurrencyNotSettled(); diff --git a/src/interfaces/uniswapv4/IPositionManager.sol b/src/interfaces/uniswapv4/IPositionManager.sol index 37bc4cc..c66ebb7 100644 --- a/src/interfaces/uniswapv4/IPositionManager.sol +++ b/src/interfaces/uniswapv4/IPositionManager.sol @@ -30,4 +30,8 @@ interface IPositionManager is IERC721 { function poolManager() external view returns (IPoolManager); function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable; + + /// @notice Used to get the ID that will be used for the next minted liquidity position + /// @return uint256 The next token ID + function nextTokenId() external view returns (uint256); } diff --git a/src/interfaces/uniswapv4/Types.sol b/src/interfaces/uniswapv4/Types.sol index f6fe19a..f193293 100644 --- a/src/interfaces/uniswapv4/Types.sol +++ b/src/interfaces/uniswapv4/Types.sol @@ -78,3 +78,13 @@ struct ExactInputSingleParams { uint128 amountOutMinimum; bytes hookData; } + +/// @notice Parameter struct for `Swap` pool operations +struct SwapParams { + /// Whether to swap token0 for token1 or vice versa + bool zeroForOne; + /// The desired input amount if negative (exactIn), or the desired output amount if positive (exactOut) + int256 amountSpecified; + /// The sqrt price at which, if reached, the swap will stop executing + uint160 sqrtPriceLimitX96; +} diff --git a/src/libraries/pancakev4/CLPoolParametersHelper.sol b/src/libraries/pancakev4/CLPoolParametersHelper.sol new file mode 100644 index 0000000..3b9b407 --- /dev/null +++ b/src/libraries/pancakev4/CLPoolParametersHelper.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2024 PancakeSwap +pragma solidity ^0.8.0; + +/** + * @title Concentrated Liquidity Pair Parameter Helper Library + * @dev This library contains functions to get and set parameters of a pair + * The parameters are stored in a single bytes32 variable in the following format: + * + * [0 - 15[: reserve for hooks + * [16 - 39[: tickSpacing (24 bits) + * [40 - 256[: unused + */ +library CLPoolParametersHelper { + uint256 internal constant OFFSET_TICK_SPACING = 16; + uint256 internal constant OFFSET_MOST_SIGNIFICANT_UNUSED_BITS = 40; + + /** + * @dev Get tickSpacing from the encoded pair parameters + * @param params The encoded pair parameters, as follows: + * [0 - 16[: hooks registration bitmaps + * [16 - 39[: tickSpacing (24 bits) + * [40 - 256[: unused + * @return tickSpacing The tickSpacing + */ + function getTickSpacing(bytes32 params) internal pure returns (int24 tickSpacing) { + assembly ('memory-safe') { + tickSpacing := and(shr(OFFSET_TICK_SPACING, params), 0xFFFFFF) + } + } +} diff --git a/src/libraries/uniswapv4/FixedPoint128.sol b/src/libraries/uniswapv4/FixedPoint128.sol new file mode 100644 index 0000000..cb213c2 --- /dev/null +++ b/src/libraries/uniswapv4/FixedPoint128.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title FixedPoint128 +/// @notice A library for handling binary fixed point numbers, see https://en.wikipedia.org/wiki/Q_(number_format) +library FixedPoint128 { + uint256 internal constant Q128 = 0x100000000000000000000000000000000; +} diff --git a/test/MockAction.t.sol b/test/MockAction.t.sol index ba4851e..70a6744 100644 --- a/test/MockAction.t.sol +++ b/test/MockAction.t.sol @@ -15,6 +15,8 @@ contract MockActionTest is BaseTest { using ArraysHelper for *; using SafeERC20 for IERC20; + uint256 internal constant ERC721_IDS_SEPARATOR = type(uint256).max; + uint256 nonce = 0; MockHook mockHook; @@ -68,9 +70,9 @@ contract MockActionTest is BaseTest { function testMockActionExecuteSuccessP256(uint256 seed, bool testOnSepolia) public { if (testOnSepolia) { - vm.createSelectFork(vm.envString('SEPOLIA_NODE_URL'), 9_598_379); + vm.createSelectFork('sepolia', 9_598_379); } else { - vm.createSelectFork(vm.envString('OP_NODE_URL'), 143_581_448); + vm.createSelectFork('optimism_mainnet', 143_581_448); } _setupP256(); @@ -119,9 +121,9 @@ contract MockActionTest is BaseTest { function testMockActionExecuteSuccessWebAuthn(uint256 seed, bool testOnSepolia) public { if (testOnSepolia) { - vm.createSelectFork(vm.envString('SEPOLIA_NODE_URL'), 9_598_379); + vm.createSelectFork('sepolia', 9_598_379); } else { - vm.createSelectFork(vm.envString('OP_NODE_URL'), 143_581_448); + vm.createSelectFork('optimism_mainnet', 143_581_448); } _setupP256(); @@ -268,6 +270,150 @@ contract MockActionTest is BaseTest { router.execute(intentData, dkSignature, guardian, gdSignature, actionData); } + function testMockActionExecuteSuccess_ERC721Wildcard(uint256 tokenId0, uint256 tokenId1) public { + tokenId0 = bound(tokenId0, 1, type(uint256).max - 2); + tokenId1 = bound(tokenId1, 1, type(uint256).max - 2); + vm.assume(tokenId0 != tokenId1); + + IntentData memory intentData = _getWildcardERC721IntentData(''); + vm.startPrank(mainAddress); + erc721Mock.mint(mainAddress, tokenId0); + erc721Mock.mint(mainAddress, tokenId1); + erc721Mock.setApprovalForAll(address(router), true); + router.delegate(intentData); + vm.stopPrank(); + + ActionData memory actionData = + _getWildcardERC721ActionData(2, [tokenId0, tokenId1].toMemoryArray()); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + + assertEq(erc721Mock.ownerOf(tokenId0), address(forwarder)); + assertEq(erc721Mock.ownerOf(tokenId1), address(forwarder)); + } + + function testMockActionExecuteRevert_ERC721WildcardMissingSuffix( + uint256 tokenId0, + uint256 tokenId1 + ) public { + tokenId0 = bound(tokenId0, 1, type(uint256).max - 2); + tokenId1 = bound(tokenId1, 1, type(uint256).max - 2); + vm.assume(tokenId0 != tokenId1); + + IntentData memory intentData = _getWildcardERC721IntentData(''); + vm.startPrank(mainAddress); + erc721Mock.mint(mainAddress, tokenId0); + erc721Mock.mint(mainAddress, tokenId1); + erc721Mock.setApprovalForAll(address(router), true); + router.delegate(intentData); + vm.stopPrank(); + + ActionData memory actionData = _getWildcardERC721ActionData(1, new uint256[](0)); + actionData.erc721Ids = [uint256(0)].toMemoryArray(); + + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + vm.prank(caller); + vm.expectRevert(); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testMockActionExecuteSuccess_ERC721WildcardSubsetSuffix( + uint256 tokenId0, + uint256 tokenId1 + ) public { + tokenId0 = bound(tokenId0, 1, type(uint256).max - 2); + tokenId1 = bound(tokenId1, 1, type(uint256).max - 2); + vm.assume(tokenId0 != tokenId1); + + IntentData memory intentData = _getWildcardERC721IntentData(''); + vm.startPrank(mainAddress); + erc721Mock.mint(mainAddress, tokenId0); + erc721Mock.mint(mainAddress, tokenId1); + erc721Mock.setApprovalForAll(address(router), true); + router.delegate(intentData); + vm.stopPrank(); + + ActionData memory actionData = _getWildcardERC721ActionData(1, [tokenId0].toMemoryArray()); + + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + vm.prank(caller); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + + assertEq(erc721Mock.ownerOf(tokenId0), address(forwarder)); + assertEq(erc721Mock.ownerOf(tokenId1), mainAddress); + } + + function testMockActionExecuteSuccess_ERC721SuffixWithoutWildcard( + uint256 tokenId0, + uint256 tokenId1 + ) public { + tokenId0 = bound(tokenId0, 1, type(uint256).max - 1); + tokenId1 = bound(tokenId1, 1, type(uint256).max - 1); + vm.assume(tokenId0 != tokenId1); + + IntentData memory intentData = _getIntentData(tokenId0); + vm.startPrank(mainAddress); + erc721Mock.mint(mainAddress, tokenId1); + erc721Mock.setApprovalForAll(address(router), true); + router.delegate(intentData); + vm.stopPrank(); + + ActionData memory actionData = _getActionData(intentData.tokenData, abi.encode(''), tokenId0); + actionData.erc721Ids = [uint256(0), ERC721_IDS_SEPARATOR, tokenId1].toMemoryArray(); + + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + vm.prank(caller); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + + assertEq(erc721Mock.ownerOf(tokenId0), address(forwarder)); + assertEq(erc721Mock.ownerOf(tokenId1), mainAddress); + } + + function testMockActionDelegateSuccess_ERC721WildcardWithPermit() public { + IntentData memory intentData = _getWildcardERC721IntentData(hex'01'); + + vm.prank(mainAddress); + router.delegate(intentData); + } + + function testMockActionExecuteRevert_ERC721WildcardMismatchedSuffixFormat( + uint256 tokenId0, + uint256 tokenId1 + ) public { + tokenId0 = bound(tokenId0, 1, type(uint256).max - 1); + tokenId1 = bound(tokenId1, 1, type(uint256).max - 1); + vm.assume(tokenId0 != tokenId1); + + IntentData memory intentData = _getWildcardERC721IntentData(''); + vm.startPrank(mainAddress); + erc721Mock.mint(mainAddress, tokenId0); + erc721Mock.mint(mainAddress, tokenId1); + erc721Mock.setApprovalForAll(address(router), true); + router.delegate(intentData); + vm.stopPrank(); + + ActionData memory actionData = + _getWildcardERC721ActionData(2, [tokenId0, tokenId1].toMemoryArray()); + actionData.erc721Ids = new uint256[](5); + actionData.erc721Ids[0] = 0; + actionData.erc721Ids[1] = 0; + actionData.erc721Ids[2] = ERC721_IDS_SEPARATOR; + actionData.erc721Ids[3] = tokenId0; + actionData.erc721Ids[4] = ERC721_IDS_SEPARATOR; + + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + vm.prank(caller); + vm.expectRevert(); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + function testMockActionExecuteRevokedIntentShouldRevert(uint256 seed) public { uint256 mode = bound(seed, 0, 2); IntentData memory intentData = _getIntentData(seed); @@ -614,6 +760,30 @@ contract MockActionTest is BaseTest { vm.stopPrank(); } + function _getWildcardERC721IntentData(bytes memory permitData) + internal + view + returns (IntentData memory intentData) + { + IntentCoreData memory coreData = IntentCoreData({ + mainAddress: mainAddress, + signatureVerifier: verifier, + delegatedKey: delegatedPublicKey, + actionContracts: [address(mockActionContract)].toMemoryArray(), + actionSelectors: [MockActionContract.execute.selector].toMemoryArray(), + hook: address(mockHook), + hookIntentData: '' + }); + + TokenData memory tokenData; + tokenData.erc20Data = new ERC20Data[](0); + tokenData.erc721Data = new ERC721Data[](1); + tokenData.erc721Data[0] = + ERC721Data({token: address(erc721Mock), tokenId: type(uint256).max, permitData: permitData}); + + intentData = IntentData({coreData: coreData, tokenData: tokenData, extraData: ''}); + } + function _getActionData(TokenData memory tokenData, bytes memory actionCalldata, uint256 seed) internal returns (ActionData memory actionData) @@ -653,6 +823,44 @@ contract MockActionTest is BaseTest { }); } + function _getWildcardERC721ActionData(uint256 wildcardCopies, uint256[] memory actualTokenIds) + internal + returns (ActionData memory actionData) + { + uint256[] memory erc721Ids = + new uint256[](wildcardCopies + (actualTokenIds.length > 0 ? 1 + actualTokenIds.length : 0)); + for (uint256 i = 0; i < wildcardCopies; i++) { + erc721Ids[i] = 0; + } + if (actualTokenIds.length > 0) { + uint256 cursor = wildcardCopies; + erc721Ids[cursor] = ERC721_IDS_SEPARATOR; + cursor++; + for (uint256 i = 0; i < actualTokenIds.length; i++) { + erc721Ids[cursor] = actualTokenIds[i]; + cursor++; + } + } + + FeeInfo memory feeInfo; + feeInfo.protocolRecipient = protocolRecipient; + feeInfo.partnerFeeConfigs = new FeeConfig[][](0); + + actionData = ActionData({ + erc20Ids: new uint256[](0), + erc20Amounts: new uint256[](0), + erc721Ids: erc721Ids, + feeInfo: feeInfo, + approvalFlags: 1, + actionSelectorId: 0, + actionCalldata: abi.encode(''), + hookActionData: '', + extraData: '', + deadline: block.timestamp + 1 days, + nonce: nonce++ + }); + } + function _checkAllowancesAfterDelegation(bytes32 intentHash, TokenData memory tokenData) internal view diff --git a/test/SimulateIntent.t.sol b/test/SimulateIntent.t.sol index 972ba5a..dda7501 100644 --- a/test/SimulateIntent.t.sol +++ b/test/SimulateIntent.t.sol @@ -17,7 +17,7 @@ contract SimulateIntent is Test { function setUp() public { // (Step 1) Load parameters - RPC_URL = vm.envString('BSC_NODE_URL'); + RPC_URL = 'bsc_mainnet'; BLOCK_NUMBER = 0; SENDER = 0x5ACf6f6E6c0a5B8595251416B50F6c7CF9508F7E; TARGET = 0xCa611DEb2914056D392bF77e13aCD544334dD957; diff --git a/test/mocks/MockActionContract.sol b/test/mocks/MockActionContract.sol index 52992bf..384cd90 100644 --- a/test/mocks/MockActionContract.sol +++ b/test/mocks/MockActionContract.sol @@ -2,15 +2,25 @@ pragma solidity ^0.8.0; import 'ks-common-sc/src/libraries/token/TokenHelper.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {IAllowanceTransfer} from 'ks-common-sc/src/interfaces/IAllowanceTransfer.sol'; import 'src/interfaces/IWETH.sol'; import {ICLPositionManager} from 'src/interfaces/pancakev4/ICLPositionManager.sol'; +import { + Actions as PancakeActions, + PoolId, + PoolKey as PancakePoolKey +} from 'src/interfaces/pancakev4/Types.sol'; import {IUniswapV3PM} from 'src/interfaces/uniswapv3/IUniswapV3PM.sol'; import {IPoolManager} from 'src/interfaces/uniswapv4/IPoolManager.sol'; import {IPositionManager} from 'src/interfaces/uniswapv4/IPositionManager.sol'; import {PoolKey} from 'src/interfaces/uniswapv4/Types.sol'; +import {Actions} from 'src/interfaces/uniswapv4/Types.sol'; +import {LiquidityAmounts} from 'src/libraries/uniswapv4/LiquidityAmounts.sol'; import {StateLibrary} from 'src/libraries/uniswapv4/StateLibrary.sol'; +import {TickMath} from 'src/libraries/uniswapv4/TickMath.sol'; struct UniswapV4Data { address posManager; @@ -31,6 +41,9 @@ contract MockActionContract { uint256 constant TAKE_PAIR = 0x11; uint256 constant NOT_TRANSFER = uint256(keccak256('NOT_TRANSFER')); + address internal constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + address internal constant PERMIT2_BSC = 0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768; + function execute(bytes calldata data) external { if (data.length > 0) { (address token, address router) = abi.decode(data, (address, address)); @@ -270,6 +283,251 @@ contract MockActionContract { emit Transferred(amounts[0] * transferPercent / 1e6, amounts[1] * transferPercent / 1e6); } + // ─── Zap-migrate mocks ──────────────────────────────────────────────────── + + struct ZapMigrateUniswapV3Params { + IUniswapV3PM pm; + uint256 oldTokenId; + int24 newTickLower; + int24 newTickUpper; + address router; + address mainAddress; + uint256[] amountDesireds; + uint256[] fees; + } + + function zapMigrateUniswapV3(ZapMigrateUniswapV3Params memory params) external { + (,, address token0, address token1, uint24 fee,,, uint128 liquidity,,,,) = + params.pm.positions(params.oldTokenId); + + if (liquidity > 0) { + params.pm + .decreaseLiquidity( + IUniswapV3PM.DecreaseLiquidityParams({ + tokenId: params.oldTokenId, + liquidity: liquidity, + amount0Min: 0, + amount1Min: 0, + deadline: block.timestamp + 1 days + }) + ); + } + + uint256 balance0Before = IERC20(token0).balanceOf(address(this)); + uint256 balance1Before = IERC20(token1).balanceOf(address(this)); + + params.pm + .collect( + IUniswapV3PM.CollectParams({ + tokenId: params.oldTokenId, + recipient: address(this), + amount0Max: type(uint128).max, + amount1Max: type(uint128).max + }) + ); + + uint256 collected0 = IERC20(token0).balanceOf(address(this)) - balance0Before; + uint256 collected1 = IERC20(token1).balanceOf(address(this)) - balance1Before; + + uint256 feeAmount0 = collected0 * params.fees[0] / 1e6; + uint256 feeAmount1 = collected1 * params.fees[1] / 1e6; + + if (feeAmount0 > 0) token0.safeTransfer(params.router, feeAmount0); + if (feeAmount1 > 0) token1.safeTransfer(params.router, feeAmount1); + + token0.forceApprove(address(params.pm), params.amountDesireds[0]); + token1.forceApprove(address(params.pm), params.amountDesireds[1]); + + params.pm + .mint( + IUniswapV3PM.MintParams({ + token0: token0, + token1: token1, + fee: fee, + tickLower: params.newTickLower, + tickUpper: params.newTickUpper, + amount0Desired: params.amountDesireds[0], + amount1Desired: params.amountDesireds[1], + amount0Min: 0, + amount1Min: 0, + recipient: params.mainAddress, + deadline: block.timestamp + 1 days + }) + ); + } + + struct ZapMigrateUniswapV4Params { + IPositionManager pm; + uint256 oldTokenId; + int24 newTickLower; + int24 newTickUpper; + address router; + address mainAddress; + uint256[] amountDesireds; + uint256[] fees; + } + + function _univ4PoolId(PoolKey memory key) private pure returns (bytes32 id) { + assembly ('memory-safe') { + id := keccak256(key, 0xa0) + } + } + + function _pancakev4PoolId(PancakePoolKey memory key) private pure returns (PoolId id) { + assembly ('memory-safe') { + id := keccak256(key, 0xc0) + } + } + + function _minU256(uint256 a, uint256 b) private pure returns (uint256) { + return a < b ? a : b; + } + + /// @dev Same fee rule as `zapMigrateUniswapV3`: fee_i = collected_i * fees[i] / 1e6, sent to `router`. + function zapMigrateUniswapV4(ZapMigrateUniswapV4Params memory params) external payable { + IPositionManager pm = params.pm; + (PoolKey memory poolKey,) = pm.getPoolAndPositionInfo(params.oldTokenId); + uint128 posLiq = pm.getPositionLiquidity(params.oldTokenId); + + bytes32 poolId = _univ4PoolId(poolKey); + (uint160 sqrtP,,,) = pm.poolManager().getSlot0(poolId); + + uint256 balance0Before = poolKey.currency0.selfBalance(); + uint256 balance1Before = poolKey.currency1.selfBalance(); + + bytes memory takeActions = new bytes(2); + bytes[] memory takeParams = new bytes[](2); + takeActions[0] = bytes1(uint8(Actions.DECREASE_LIQUIDITY)); + takeParams[0] = abi.encode(params.oldTokenId, posLiq, uint128(0), uint128(0), bytes('')); + takeActions[1] = bytes1(uint8(Actions.TAKE_PAIR)); + takeParams[1] = abi.encode(poolKey.currency0, poolKey.currency1, address(this)); + pm.modifyLiquidities(abi.encode(takeActions, takeParams), type(uint256).max); + + uint256 collected0 = poolKey.currency0.selfBalance() - balance0Before; + uint256 collected1 = poolKey.currency1.selfBalance() - balance1Before; + + uint256 fee0 = collected0 * params.fees[0] / 1e6; + uint256 fee1 = collected1 * params.fees[1] / 1e6; + poolKey.currency0.safeTransfer(params.router, fee0); + poolKey.currency1.safeTransfer(params.router, fee1); + + uint128 newLiq = LiquidityAmounts.getLiquidityForAmounts( + sqrtP, + TickMath.getSqrtRatioAtTick(params.newTickLower), + TickMath.getSqrtRatioAtTick(params.newTickUpper), + params.amountDesireds[0], + params.amountDesireds[1] + ); + + if (!poolKey.currency0.isNative()) { + poolKey.currency0.forceApprove(PERMIT2, type(uint256).max); + IAllowanceTransfer(PERMIT2) + .approve(poolKey.currency0, address(pm), type(uint160).max, type(uint48).max); + } + if (!poolKey.currency1.isNative()) { + poolKey.currency1.forceApprove(PERMIT2, type(uint256).max); + IAllowanceTransfer(PERMIT2) + .approve(poolKey.currency1, address(pm), type(uint160).max, type(uint48).max); + } + + bytes memory mintActions = new bytes(2); + bytes[] memory mintParams = new bytes[](2); + mintActions[0] = bytes1(uint8(Actions.MINT_POSITION)); + mintParams[0] = abi.encode( + poolKey, + params.newTickLower, + params.newTickUpper, + uint256(newLiq), + params.amountDesireds[0], + params.amountDesireds[1], + params.mainAddress, + bytes('') + ); + mintActions[1] = bytes1(uint8(Actions.SETTLE_PAIR)); + mintParams[1] = abi.encode(poolKey.currency0, poolKey.currency1); + + uint256 ethValue = poolKey.currency0.isNative() ? address(this).balance : 0; + pm.modifyLiquidities{value: ethValue}(abi.encode(mintActions, mintParams), type(uint256).max); + } + + struct ZapMigratePancakeV4Params { + ICLPositionManager pm; + uint256 oldTokenId; + int24 newTickLower; + int24 newTickUpper; + address router; + address mainAddress; + uint256[] amountDesireds; + uint256[] fees; + } + + function zapMigratePancakeV4(ZapMigratePancakeV4Params memory params) external payable { + ICLPositionManager pm = params.pm; + (PancakePoolKey memory poolKey,,, uint128 posLiq,,,) = pm.positions(params.oldTokenId); + + PoolId poolId = _pancakev4PoolId(poolKey); + (uint160 sqrtP,,,) = pm.clPoolManager().getSlot0(poolId); + + uint256 balance0Before = poolKey.currency0.selfBalance(); + uint256 balance1Before = poolKey.currency1.selfBalance(); + + bytes memory takeActions = new bytes(2); + bytes[] memory takeParams = new bytes[](2); + takeActions[0] = bytes1(uint8(PancakeActions.CL_DECREASE_LIQUIDITY)); + takeParams[0] = abi.encode(params.oldTokenId, posLiq, uint128(0), uint128(0), bytes('')); + takeActions[1] = bytes1(uint8(PancakeActions.TAKE_PAIR)); + takeParams[1] = abi.encode(poolKey.currency0, poolKey.currency1, address(this)); + pm.modifyLiquidities(abi.encode(takeActions, takeParams), type(uint256).max); + + uint256 collected0 = poolKey.currency0.selfBalance() - balance0Before; + uint256 collected1 = poolKey.currency1.selfBalance() - balance1Before; + + uint256 fee0 = collected0 * params.fees[0] / 1e6; + uint256 fee1 = collected1 * params.fees[1] / 1e6; + poolKey.currency0.safeTransfer(params.router, fee0); + poolKey.currency1.safeTransfer(params.router, fee1); + + uint128 newLiq = LiquidityAmounts.getLiquidityForAmounts( + sqrtP, + TickMath.getSqrtRatioAtTick(params.newTickLower), + TickMath.getSqrtRatioAtTick(params.newTickUpper), + params.amountDesireds[0], + params.amountDesireds[1] + ); + + if (poolKey.currency0 != TokenHelper.NATIVE_ADDRESS) { + poolKey.currency0.safeApprove(PERMIT2_BSC, 0); + poolKey.currency0.safeApprove(PERMIT2_BSC, type(uint256).max); + IAllowanceTransfer(PERMIT2_BSC) + .approve(poolKey.currency0, address(pm), type(uint160).max, type(uint48).max); + } + if (poolKey.currency1 != TokenHelper.NATIVE_ADDRESS) { + poolKey.currency1.safeApprove(PERMIT2_BSC, 0); + poolKey.currency1.safeApprove(PERMIT2_BSC, type(uint256).max); + IAllowanceTransfer(PERMIT2_BSC) + .approve(poolKey.currency1, address(pm), type(uint160).max, type(uint48).max); + } + + bytes memory mintActions = new bytes(2); + bytes[] memory mintParams = new bytes[](2); + mintActions[0] = bytes1(uint8(PancakeActions.CL_MINT_POSITION)); + mintParams[0] = abi.encode( + poolKey, + params.newTickLower, + params.newTickUpper, + newLiq, + params.amountDesireds[0], + params.amountDesireds[1], + params.mainAddress, + bytes('') + ); + mintActions[1] = bytes1(uint8(PancakeActions.SETTLE_PAIR)); + mintParams[1] = abi.encode(poolKey.currency0, poolKey.currency1); + + uint256 ethValue = poolKey.currency0 == TokenHelper.NATIVE_ADDRESS ? address(this).balance : 0; + pm.modifyLiquidities{value: ethValue}(abi.encode(mintActions, mintParams), type(uint256).max); + } + fallback() external payable {} receive() external payable {} diff --git a/test/RemoveLiquidityPancakeV4CL.t.sol b/test/remove-liq/RemoveLiquidityPancakeV4CL.t.sol similarity index 99% rename from test/RemoveLiquidityPancakeV4CL.t.sol rename to test/remove-liq/RemoveLiquidityPancakeV4CL.t.sol index 5500792..5db121e 100644 --- a/test/RemoveLiquidityPancakeV4CL.t.sol +++ b/test/remove-liq/RemoveLiquidityPancakeV4CL.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; -import './Base.t.sol'; +import '../Base.t.sol'; import 'src/hooks/base/BaseConditionalHook.sol'; diff --git a/test/RemoveLiquidityUniswapV3.t.sol b/test/remove-liq/RemoveLiquidityUniswapV3.t.sol similarity index 99% rename from test/RemoveLiquidityUniswapV3.t.sol rename to test/remove-liq/RemoveLiquidityUniswapV3.t.sol index e70ce80..be07950 100644 --- a/test/RemoveLiquidityUniswapV3.t.sol +++ b/test/remove-liq/RemoveLiquidityUniswapV3.t.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; -import './Base.t.sol'; +import '../Base.t.sol'; import 'src/hooks/base/BaseConditionalHook.sol'; import 'src/hooks/remove-liq/KSRemoveLiquidityUniswapV3Hook.sol'; -import {IERC721} from 'src/interfaces/uniswapv3/IUniswapV3PM.sol'; +import {IERC721} from 'openzeppelin-contracts/contracts/interfaces/IERC721.sol'; import 'test/common/Permit.sol'; import 'src/types/ConditionTree.sol'; diff --git a/test/RemoveLiquidityUniswapV4.t.sol b/test/remove-liq/RemoveLiquidityUniswapV4.t.sol similarity index 99% rename from test/RemoveLiquidityUniswapV4.t.sol rename to test/remove-liq/RemoveLiquidityUniswapV4.t.sol index 60f7257..7b60bf8 100644 --- a/test/RemoveLiquidityUniswapV4.t.sol +++ b/test/remove-liq/RemoveLiquidityUniswapV4.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.0; -import './Base.t.sol'; +import '../Base.t.sol'; import 'ks-common-sc/src/libraries/token/TokenHelper.sol'; import 'src/hooks/base/BaseConditionalHook.sol'; diff --git a/test/zap-migrate/ZapMigrateFuzzParams.sol b/test/zap-migrate/ZapMigrateFuzzParams.sol new file mode 100644 index 0000000..b207140 --- /dev/null +++ b/test/zap-migrate/ZapMigrateFuzzParams.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +struct ZapMigrateFuzzParams { + uint8 mintRangeMultiplier; + uint8 newRangeMultiplier; + uint256 mintAmount0Desired; + uint256 mintAmount1Desired; + uint256[2] actionAmount0Desireds; + uint256[2] actionAmount1Desireds; + uint24[2] actionFee0s; + uint24[2] actionFee1s; + int24 newTickAfterSwap; + uint256 maxFee0; + uint256 maxFee1; + int24 maxDistanceFromLowerTickBeforeMigration; + int24 maxDistanceFromUpperTickBeforeMigration; + int24 minDistanceFromLowerTickAfterMigration; + int24 minDistanceFromUpperTickAfterMigration; + int24 minTickRangeLength; + int24 maxTickRangeLength; + uint256 minValueInToken0; + uint256 minValueInToken1; + uint256 maxValueReductionPerAction; + uint256 invalidTokenErc20Amount; + uint256 samplePositionIndex; +} diff --git a/test/zap-migrate/ZapMigratePancakeV4CL.t.sol b/test/zap-migrate/ZapMigratePancakeV4CL.t.sol new file mode 100644 index 0000000..d0811ed --- /dev/null +++ b/test/zap-migrate/ZapMigratePancakeV4CL.t.sol @@ -0,0 +1,831 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import '../Base.t.sol'; + +import 'src/hooks/base/BaseHook.sol'; +import 'src/hooks/base/BaseTickBasedZapMigrateHook.sol'; +import { + KSZapMigrateUniswapV3Hook as KSZapMigratePancakeV4CLHook +} from 'src/hooks/zap-migrate/KSZapMigratePancakeV4CLHook.sol'; + +import {IAllowanceTransfer} from 'ks-common-sc/src/interfaces/IAllowanceTransfer.sol'; +import {TokenHelper} from 'ks-common-sc/src/libraries/token/TokenHelper.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {ICLPoolManager} from 'src/interfaces/pancakev4/ICLPoolManager.sol'; +import {ICLPositionManager} from 'src/interfaces/pancakev4/ICLPositionManager.sol'; +import {IVault} from 'src/interfaces/pancakev4/IVault.sol'; +import {Actions, BalanceDelta, PoolId, PoolKey} from 'src/interfaces/pancakev4/Types.sol'; +import {LiquidityAmounts} from 'src/libraries/uniswapv4/LiquidityAmounts.sol'; +import {TickMath} from 'src/libraries/uniswapv4/TickMath.sol'; + +import './ZapMigrateFuzzParams.sol'; + +contract ZapMigratePancakeV4CLTest is BaseTest { + using ArraysHelper for *; + using TokenHelper for address; + + ICLPositionManager internal constant PM = + ICLPositionManager(0x55f4c8abA71A1e923edC303eb4fEfF14608cC226); + + address internal constant TOKEN0 = address(0); + address internal constant TOKEN1 = 0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82; + address internal constant POOL_HOOKS = 0x32C59D556B16DB81DFc32525eFb3CB257f7e493d; + address internal constant CL_POOL_MANAGER = 0xa0FfB9c1CE1Fe56963B0321B32E7A0302114058b; + address internal constant VAULT_ADDRESS = 0x238a358808379702088667322f80aC48bAd5e6c4; + address internal constant PERMIT2 = 0x31c2F6fcFf4F8759b3Bd5Bf0e1084A055615c768; + uint24 internal constant POOL_FEE = 8_388_608; + int24 internal constant TICK_SPACING = 10; + bytes32 internal constant POOL_PARAMETERS = + 0x00000000000000000000000000000000000000000000000000000000000a00c2; + + uint256 internal constant FEE_PRECISION = 1_000_000; + + KSZapMigratePancakeV4CLHook internal zapHook; + ICLPoolManager internal poolManager; + IVault internal vault; + + struct PositionContext { + address token0; + address token1; + int24 tickLower; + int24 tickUpper; + int24 currentTick; + bytes32 poolUniqueId; + } + + struct SwapLockData { + int24 targetTick; + bool zeroForOne; + } + + struct SuccessMigrationSetup { + IntentData intentData; + ActionData actionData0; + int24 targetTickForSecondMigration; + uint256 amount0Desired0; + uint256 amount1Desired0; + uint256 amount0Desired1; + uint256 amount1Desired1; + uint24 actionFee01; + uint24 actionFee11; + } + + function _selectFork() public override { + FORK_BLOCK = 56_756_230; + vm.createSelectFork('bsc_mainnet', FORK_BLOCK); + } + + function setUp() public override { + super.setUp(); + poolManager = PM.clPoolManager(); + vault = IVault(VAULT_ADDRESS); + zapHook = new KSZapMigratePancakeV4CLHook([address(router)].toMemoryArray()); + } + + function testFuzz_ZapMigratePancakeV4CL_Success(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + SuccessMigrationSetup memory setup = _prepareSuccessFirstMigration(fuzz, oldNftId, pos); + + deal(address(mockActionContract), setup.amount0Desired0); + deal(pos.token1, address(mockActionContract), setup.amount1Desired0); + + vm.prank(address(forwarder)); + router.delegate(setup.intentData); + _executeDelegated(setup.intentData, setup.actionData0); + + bytes32 intentHash = router.hashTypedIntentData(setup.intentData); + uint256 firstMigratedNftId = zapHook.nftIds(intentHash); + assertTrue(firstMigratedNftId != 0, 'first migrated nft id is not recorded'); + assertTrue(firstMigratedNftId != oldNftId, 'first migrated nft id must differ from old nft id'); + assertEq( + PM.ownerOf(firstMigratedNftId), address(forwarder), 'first migrated nft owner mismatch' + ); + + vm.prank(address(forwarder)); + PM.approve(address(mockActionContract), firstMigratedNftId); + vm.prank(address(forwarder)); + PM.approve(address(router), oldNftId); + + PositionContext memory posBeforeSwap = _positionContext(firstMigratedNftId); + int24 targetTick = _clampInt24( + setup.targetTickForSecondMigration, posBeforeSwap.tickLower, posBeforeSwap.tickUpper + ); + PositionContext memory posAfterSwap = + _movePoolTickToTarget(firstMigratedNftId, posBeforeSwap, targetTick); + + (int24 newTickLower1, int24 newTickUpper1) = + _newTicks(posAfterSwap.currentTick, fuzz.newRangeMultiplier); + ActionData memory actionData1 = _buildActionData( + firstMigratedNftId, + newTickLower1, + newTickUpper1, + address(forwarder), + setup.amount0Desired1, + setup.amount1Desired1, + setup.actionFee01, + setup.actionFee11 + ); + actionData1.nonce = 1; + + deal(address(mockActionContract), setup.amount0Desired1); + deal(posAfterSwap.token1, address(mockActionContract), setup.amount1Desired1); + + _executeDelegated(setup.intentData, actionData1); + + uint256 secondMigratedNftId = zapHook.nftIds(intentHash); + + assertTrue(secondMigratedNftId != 0, 'second migrated nft id is not recorded'); + assertTrue( + secondMigratedNftId != firstMigratedNftId, + 'second migrated nft id must differ from first migrated nft id' + ); + assertEq(PM.ownerOf(oldNftId), address(forwarder), 'old nft owner mismatch'); + assertEq( + PM.ownerOf(firstMigratedNftId), address(forwarder), 'first migrated nft owner mismatch' + ); + assertEq( + PM.ownerOf(secondMigratedNftId), address(forwarder), 'second migrated nft owner mismatch' + ); + } + + function testFuzz_Revert_InvalidTokenData(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + uint256 amount0Desired = bound(fuzz.actionAmount0Desireds[0], 0.01 ether, 1 ether); + uint256 amount1Desired = bound(fuzz.actionAmount1Desireds[0], 100e6, 2000e6); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), amount0Desired, amount1Desired, 0, 0 + ); + actionData.erc20Ids = [uint256(0)].toMemoryArray(); + actionData.erc20Amounts = [bound(fuzz.invalidTokenErc20Amount, 0, 1e18)].toMemoryArray(); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseHook.InvalidTokenData.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooLargeDistanceFromTickBoundaries(ZapMigrateFuzzParams memory fuzz) + public + { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + hookData.maxDistanceFromLowerTickBeforeMigration = (pos.currentTick - pos.tickLower) - 1; + hookData.maxDistanceFromUpperTickBeforeMigration = (pos.tickUpper - pos.currentTick) - 1; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + vm.expectRevert(BaseTickBasedZapMigrateHook.TooLargeDistanceFromTickBoundaries.selector); + vm.prank(address(router)); + zapHook.beforeExecution(bytes32(0), intentData, actionData); + } + + function testFuzz_Revert_InvalidOwner(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + mainAddress, fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + uint256 amount0Desired = bound(fuzz.actionAmount0Desireds[0], 0.1 ether, 5 ether); + uint256 amount1Desired = bound(fuzz.actionAmount1Desireds[0], 100e6, 20_000e6); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + IntentData memory intentData = _buildIntentData(mainAddress, hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, mainAddress, amount0Desired, amount1Desired, 0, 0 + ); + + deal(address(mockActionContract), amount0Desired); + deal(pos.token1, address(mockActionContract), amount1Desired); + + vm.prank(mainAddress); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.InvalidOwner.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_ExceedMaxFeesPercent(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + uint256 amount0Desired = bound(fuzz.actionAmount0Desireds[0], 0.1 ether, 5 ether); + uint256 amount1Desired = bound(fuzz.actionAmount1Desireds[0], 100e6, 20_000e6); + uint24 actionFee0 = uint24(bound(fuzz.actionFee0s[0], 1, FEE_PRECISION)); + uint24 actionFee1 = uint24(bound(fuzz.actionFee1s[0], 1, FEE_PRECISION)); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, actionFee0, actionFee1); + hookData.maxFee0 = bound(fuzz.maxFee0, 0, uint256(actionFee0 - 1)); + hookData.maxFee1 = bound(fuzz.maxFee1, 0, uint256(actionFee1 - 1)); + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, + newTickLower, + newTickUpper, + address(forwarder), + amount0Desired, + amount1Desired, + actionFee0, + actionFee1 + ); + + deal(address(mockActionContract), amount0Desired); + deal(pos.token1, address(mockActionContract), amount1Desired); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.ExceedMaxFeesPercent.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooSmallDistanceFromTickBoundaries(ZapMigrateFuzzParams memory fuzz) + public + { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + int24 requiredExtraDistance = int24(uint24(_clamp(fuzz.samplePositionIndex, 1, 10_000))); + hookData.minDistanceFromLowerTickAfterMigration = + (pos.currentTick - newTickLower) + requiredExtraDistance; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + deal(address(mockActionContract), 0.1 ether); + deal(pos.token1, address(mockActionContract), 500e6); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.TooSmallDistanceFromTickBoundaries.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooSmallTickRangeLength(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + int24 range = newTickUpper - newTickLower; + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + int24 minIncrease = int24(uint24(_clamp(fuzz.invalidTokenErc20Amount, 1, 10_000))); + hookData.minTickRangeLength = range + minIncrease; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + deal(address(mockActionContract), 0.1 ether); + deal(pos.token1, address(mockActionContract), 500e6); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.TooSmallTickRangeLength.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooLargeTickRangeLength(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + int24 range = newTickUpper - newTickLower; + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + uint256 maxDecrease = uint256(uint24(range - 1)); + int24 decrease = int24(uint24(_clamp(fuzz.samplePositionIndex, 1, maxDecrease))); + hookData.maxTickRangeLength = range - decrease; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + deal(address(mockActionContract), 0.1 ether); + deal(pos.token1, address(mockActionContract), 500e6); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.TooLargeTickRangeLength.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_InsufficientPositionValue(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + hookData.minValueInToken0 = type(uint128).max + bound(fuzz.minValueInToken0, 1, 1e30); + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + deal(address(mockActionContract), 0.1 ether); + deal(pos.token1, address(mockActionContract), 500e6); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.InsufficientPositionValue.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_InvalidPoolUniqueId(ZapMigrateFuzzParams memory fuzz) public { + (uint256 nftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = _minimalHookData(nftId); + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, nftId); + + bytes32 invalidPoolUniqueId = + pos.poolUniqueId ^ bytes32(_clamp(fuzz.invalidTokenErc20Amount, 1, type(uint256).max)); + BaseTickBasedZapMigrateHook.BeforeExecutionData memory beforeData = + BaseTickBasedZapMigrateHook.BeforeExecutionData({ + originalNftId: nftId, + poolUniqueId: invalidPoolUniqueId, + amount0Before: 1, + amount1Before: 1, + balance0Before: 0, + balance1Before: 0, + sqrtPriceX96Before: type(uint160).max, + directionalPositionValue: 1, + direction: true, + additionalData: '' + }); + + vm.prank(address(router)); + vm.expectRevert(BaseTickBasedZapMigrateHook.InvalidPoolUniqueId.selector); + zapHook.afterExecution(bytes32(0), intentData, abi.encode(beforeData), ''); + } + + function testFuzz_Revert_ExceedMaxValueReductionPerAction(ZapMigrateFuzzParams memory fuzz) + public + { + uint256 nftId = PM.nextTokenId() - 1; + PositionContext memory pos = _positionContext(nftId); + address owner = PM.ownerOf(nftId); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = _minimalHookData(nftId); + hookData.maxValueReductionPerAction = + bound(fuzz.maxValueReductionPerAction, 0, FEE_PRECISION - 1); + uint256 directionalPositionValue = type(uint256).max - bound(fuzz.minValueInToken0, 0, 1e30); + IntentData memory intentData = _buildIntentData(owner, hookData, nftId); + + BaseTickBasedZapMigrateHook.BeforeExecutionData memory beforeData = + BaseTickBasedZapMigrateHook.BeforeExecutionData({ + originalNftId: nftId, + poolUniqueId: pos.poolUniqueId, + amount0Before: type(uint128).max, + amount1Before: type(uint128).max, + balance0Before: pos.token0.balanceOf(address(router)), + balance1Before: pos.token1.balanceOf(address(router)), + sqrtPriceX96Before: type(uint160).max, + directionalPositionValue: directionalPositionValue, + direction: true, + additionalData: '' + }); + + vm.prank(address(router)); + vm.expectRevert(BaseTickBasedZapMigrateHook.ExceedMaxValueReductionPerAction.selector); + zapHook.afterExecution(bytes32(0), intentData, abi.encode(beforeData), ''); + } + + function _actionParams(ZapMigrateFuzzParams memory fuzz, uint256 actionIndex) + internal + pure + returns (uint256 amount0Desired, uint256 amount1Desired, uint24 actionFee0, uint24 actionFee1) + { + amount0Desired = bound(fuzz.actionAmount0Desireds[actionIndex], 0.1 ether, 5 ether); + amount1Desired = bound(fuzz.actionAmount1Desireds[actionIndex], 100e6, 20_000e6); + actionFee0 = uint24(bound(fuzz.actionFee0s[actionIndex], 0, 50_000)); + actionFee1 = uint24(bound(fuzz.actionFee1s[actionIndex], 0, 50_000)); + } + + function _prepareSuccessFirstMigration( + ZapMigrateFuzzParams memory fuzz, + uint256 oldNftId, + PositionContext memory pos + ) internal view returns (SuccessMigrationSetup memory setup) { + uint24 actionFee00; + uint24 actionFee10; + (setup.amount0Desired0, setup.amount1Desired0, actionFee00, actionFee10) = + _actionParams(fuzz, 0); + (int24 newTickLower0, int24 newTickUpper0) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower0, newTickUpper0, actionFee00, actionFee10); + hookData.maxDistanceFromLowerTickBeforeMigration = type(int24).max; + hookData.maxDistanceFromUpperTickBeforeMigration = type(int24).max; + + setup.targetTickForSecondMigration = + _clampInt24(fuzz.newTickAfterSwap, newTickLower0, newTickUpper0); + + (setup.amount0Desired1, setup.amount1Desired1, setup.actionFee01, setup.actionFee11) = + _actionParams(fuzz, 1); + int24 minRange = newTickUpper0 - newTickLower0; + // Keep post-migration min-distance checks satisfiable for the second migration + // regardless of tick alignment after swap and range reconstruction. + hookData.minDistanceFromLowerTickAfterMigration = + _clampInt24(fuzz.minDistanceFromLowerTickAfterMigration, 0, 1); + hookData.minDistanceFromUpperTickAfterMigration = + _clampInt24(fuzz.minDistanceFromUpperTickAfterMigration, 0, 1); + hookData.minTickRangeLength = _clampInt24(fuzz.minTickRangeLength, 0, minRange); + hookData.maxTickRangeLength = _clampInt24(fuzz.maxTickRangeLength, minRange, type(int24).max); + uint256 actionFee0Max = actionFee00 > setup.actionFee01 ? actionFee00 : setup.actionFee01; + uint256 actionFee1Max = actionFee10 > setup.actionFee11 ? actionFee10 : setup.actionFee11; + hookData.maxFee0 = bound(fuzz.maxFee0, actionFee0Max, FEE_PRECISION); + hookData.maxFee1 = bound(fuzz.maxFee1, actionFee1Max, FEE_PRECISION); + + setup.intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + setup.actionData0 = _buildActionData( + oldNftId, + newTickLower0, + newTickUpper0, + address(forwarder), + setup.amount0Desired0, + setup.amount1Desired0, + actionFee00, + actionFee10 + ); + } + + function _executeDelegated(IntentData memory intentData, ActionData memory actionData) internal { + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + vm.prank(caller); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function _movePoolTickToTarget( + uint256 nftId, + PositionContext memory posBeforeSwap, + int24 targetTick + ) internal returns (PositionContext memory posAfterSwap) { + _movePoolPriceToTick(posBeforeSwap.currentTick, targetTick); + posAfterSwap = _positionContext(nftId); + } + + function _movePoolPriceToTick(int24 currentTick, int24 targetTick) internal { + if (targetTick == currentTick) return; + bool zeroForOne = targetTick < currentTick; + vm.deal(address(this), 1000 ether); + deal(TOKEN1, address(this), type(uint128).max); + vault.lock(abi.encode(SwapLockData({targetTick: targetTick, zeroForOne: zeroForOne}))); + } + + function lockAcquired(bytes calldata data) external returns (bytes memory) { + return _onLockAcquired(data); + } + + function lockCallback(bytes calldata data) external returns (bytes memory) { + return _onLockAcquired(data); + } + + function _onLockAcquired(bytes calldata data) internal returns (bytes memory) { + require(msg.sender == address(vault), 'invalid vault'); + SwapLockData memory swapData = abi.decode(data, (SwapLockData)); + BalanceDelta delta = poolManager.swap( + _poolKey(), + ICLPoolManager.SwapParams({ + zeroForOne: swapData.zeroForOne, + amountSpecified: -int256(type(int128).max), + sqrtPriceLimitX96: TickMath.getSqrtRatioAtTick(swapData.targetTick) + }), + bytes('') + ); + + int128 amount0 = _amount0(delta); + int128 amount1 = _amount1(delta); + if (amount0 < 0) _settleCurrency(TOKEN0, uint128(-amount0)); + if (amount1 < 0) _settleCurrency(TOKEN1, uint128(-amount1)); + if (amount0 > 0) vault.take(TOKEN0, address(this), uint128(amount0)); + if (amount1 > 0) vault.take(TOKEN1, address(this), uint128(amount1)); + return bytes(''); + } + + function _settleCurrency(address currency, uint256 amount) internal { + if (amount == 0) return; + if (currency.isNative()) { + vault.settle{value: amount}(); + return; + } + vault.sync(currency); + currency.safeTransfer(address(vault), amount); + vault.settle(); + } + + function _amount0(BalanceDelta delta) internal pure returns (int128 amount0) { + assembly ('memory-safe') { + amount0 := sar(128, delta) + } + } + + function _amount1(BalanceDelta delta) internal pure returns (int128 amount1) { + amount1 = int128(uint128(uint256(BalanceDelta.unwrap(delta)))); + } + + function _successHookData( + ZapMigrateFuzzParams memory fuzz, + uint256 nftId, + PositionContext memory pos, + int24 newTickLower, + int24 newTickUpper, + uint24 actionFee0, + uint24 actionFee1 + ) internal pure returns (BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData) { + int24 oldDistLower = pos.currentTick - pos.tickLower; + int24 oldDistUpper = pos.tickUpper - pos.currentTick; + int24 newDistLower = pos.currentTick - newTickLower; + int24 newDistUpper = newTickUpper - pos.currentTick; + int24 range = newTickUpper - newTickLower; + + hookData.nftId = nftId; + hookData.minValueInToken0 = bound(fuzz.minValueInToken0, 0, 1); + hookData.minValueInToken1 = bound(fuzz.minValueInToken1, 0, 1); + hookData.maxValueReductionPerAction = FEE_PRECISION; + hookData.maxDistanceFromLowerTickBeforeMigration = + _clampInt24(fuzz.maxDistanceFromLowerTickBeforeMigration, oldDistLower, type(int24).max); + hookData.maxDistanceFromUpperTickBeforeMigration = + _clampInt24(fuzz.maxDistanceFromUpperTickBeforeMigration, oldDistUpper, type(int24).max); + hookData.minDistanceFromLowerTickAfterMigration = + _clampInt24(fuzz.minDistanceFromLowerTickAfterMigration, 0, newDistLower); + hookData.minDistanceFromUpperTickAfterMigration = + _clampInt24(fuzz.minDistanceFromUpperTickAfterMigration, 0, newDistUpper); + hookData.minTickRangeLength = _clampInt24(fuzz.minTickRangeLength, 0, range); + hookData.maxTickRangeLength = _clampInt24(fuzz.maxTickRangeLength, range, type(int24).max); + hookData.maxFee0 = bound(fuzz.maxFee0, actionFee0, FEE_PRECISION); + hookData.maxFee1 = bound(fuzz.maxFee1, actionFee1, FEE_PRECISION); + } + + function _positionContext(uint256 nftId) internal view returns (PositionContext memory pos) { + (PoolKey memory poolKey, int24 tickLower, int24 tickUpper,,,,) = PM.positions(nftId); + pos.token0 = poolKey.currency0; + pos.token1 = poolKey.currency1; + pos.tickLower = tickLower; + pos.tickUpper = tickUpper; + pos.poolUniqueId = PoolId.unwrap(_toId(poolKey)); + (, pos.currentTick,,) = poolManager.getSlot0(_toId(poolKey)); + } + + function _newTicks(int24 currentTick, uint8 rangeMultiplier) + internal + pure + returns (int24 lower, int24 upper) + { + uint8 m = uint8(_clamp(rangeMultiplier, 2, 120)); + if (m % 2 == 1) { + m = m == 120 ? 119 : m + 1; + } + int24 base = currentTick / TICK_SPACING * TICK_SPACING; + if (currentTick < 0 && currentTick % TICK_SPACING != 0) { + base -= TICK_SPACING; + } + + int24 range = int24(uint24(m)) * TICK_SPACING; + lower = base - range / 2; + upper = lower + range; + + if (upper <= currentTick) { + upper += TICK_SPACING; + lower += TICK_SPACING; + } + if (lower >= currentTick) { + lower -= TICK_SPACING; + upper -= TICK_SPACING; + } + } + + function _mintFreshPosition( + address owner, + uint8 mintRangeMultiplier, + uint256 mintAmount0DesiredRaw, + uint256 mintAmount1DesiredRaw + ) internal returns (uint256 nftId, PositionContext memory pos) { + PoolId poolId = _toId(_poolKey()); + (uint160 sqrtPriceX96, int24 currentTick,,) = poolManager.getSlot0(poolId); + (int24 tickLower, int24 tickUpper) = + _newTicks(currentTick, uint8(_clamp(mintRangeMultiplier, 40, 120))); + + uint256 mintAmount0Desired = bound(mintAmount0DesiredRaw, 0.5 ether, 3 ether); + uint256 mintAmount1Desired = bound(mintAmount1DesiredRaw, 1000e6, 20_000e6); + uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + mintAmount0Desired, + mintAmount1Desired + ); + + address minter = address(this); + vm.deal(minter, mintAmount0Desired + 1 ether); + deal(TOKEN1, minter, mintAmount1Desired); + TOKEN1.safeApprove(PERMIT2, 0); + TOKEN1.safeApprove(PERMIT2, type(uint256).max); + IAllowanceTransfer(PERMIT2).approve(TOKEN1, address(PM), type(uint160).max, type(uint48).max); + + bytes memory actions = new bytes(2); + bytes[] memory params = new bytes[](2); + actions[0] = bytes1(uint8(Actions.CL_MINT_POSITION)); + params[0] = abi.encode( + _poolKey(), + tickLower, + tickUpper, + uint256(liquidity), + mintAmount0Desired, + mintAmount1Desired, + minter, + bytes('') + ); + actions[1] = bytes1(uint8(Actions.SETTLE_PAIR)); + params[1] = abi.encode(TOKEN0, TOKEN1); + + PM.modifyLiquidities{value: mintAmount0Desired}(abi.encode(actions, params), type(uint256).max); + nftId = PM.nextTokenId() - 1; + + if (owner != minter) { + PM.transferFrom(minter, owner, nftId); + } + + vm.prank(owner); + PM.approve(address(router), nftId); + + pos = _positionContext(nftId); + } + + function _buildIntentData( + address intentMainAddress, + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData, + uint256 nftId + ) internal view returns (IntentData memory intentData) { + IntentCoreData memory coreData = IntentCoreData({ + mainAddress: intentMainAddress, + signatureVerifier: address(0), + delegatedKey: delegatedPublicKey, + actionContracts: [address(mockActionContract)].toMemoryArray(), + actionSelectors: [MockActionContract.zapMigratePancakeV4.selector].toMemoryArray(), + hook: address(zapHook), + hookIntentData: abi.encode(hookData) + }); + + TokenData memory tokenData; + tokenData.erc721Data = new ERC721Data[](1); + tokenData.erc721Data[0] = ERC721Data({token: address(PM), tokenId: nftId, permitData: ''}); + + intentData = IntentData({coreData: coreData, tokenData: tokenData, extraData: ''}); + } + + function _buildActionData( + uint256 oldNftId, + int24 newTickLower, + int24 newTickUpper, + address newNftRecipient, + uint256 amount0Desired, + uint256 amount1Desired, + uint24 actionFee0, + uint24 actionFee1 + ) internal view returns (ActionData memory actionData) { + FeeInfo memory feeInfo; + feeInfo.protocolRecipient = protocolRecipient; + feeInfo.partnerFeeConfigs = new FeeConfig[][](2); + feeInfo.partnerFeeConfigs[0] = new FeeConfig[](0); + feeInfo.partnerFeeConfigs[1] = new FeeConfig[](0); + + MockActionContract.ZapMigratePancakeV4Params memory params = + MockActionContract.ZapMigratePancakeV4Params({ + pm: PM, + oldTokenId: oldNftId, + newTickLower: newTickLower, + newTickUpper: newTickUpper, + router: address(router), + mainAddress: newNftRecipient, + amountDesireds: [amount0Desired, amount1Desired].toMemoryArray(), + fees: [uint256(actionFee0), uint256(actionFee1)].toMemoryArray() + }); + + actionData = ActionData({ + erc20Ids: new uint256[](0), + erc20Amounts: new uint256[](0), + erc721Ids: [uint256(0)].toMemoryArray(), + feeInfo: feeInfo, + approvalFlags: type(uint256).max, + actionSelectorId: 0, + actionCalldata: abi.encode(params), + hookActionData: '', + extraData: '', + deadline: block.timestamp + 1 days, + nonce: 0 + }); + } + + function _minimalHookData(uint256 nftId) + internal + pure + returns (BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData) + { + hookData.nftId = nftId; + hookData.maxDistanceFromLowerTickBeforeMigration = type(int24).max; + hookData.maxDistanceFromUpperTickBeforeMigration = type(int24).max; + hookData.minDistanceFromLowerTickAfterMigration = type(int24).min; + hookData.minDistanceFromUpperTickAfterMigration = type(int24).min; + hookData.maxTickRangeLength = type(int24).max; + hookData.maxValueReductionPerAction = FEE_PRECISION; + hookData.maxFee0 = FEE_PRECISION; + hookData.maxFee1 = FEE_PRECISION; + } + + function _poolKey() internal pure returns (PoolKey memory key) { + key = PoolKey({ + currency0: TOKEN0, + currency1: TOKEN1, + hooks: POOL_HOOKS, + poolManager: CL_POOL_MANAGER, + fee: POOL_FEE, + parameters: POOL_PARAMETERS + }); + } + + function _toId(PoolKey memory key) internal pure returns (PoolId poolId) { + assembly ('memory-safe') { + poolId := keccak256(key, 0xc0) + } + } + + function _clamp(uint256 value, uint256 min, uint256 max) internal pure returns (uint256) { + if (value < min) return min; + if (value > max) return max; + return value; + } + + function _clampInt24(int24 value, int24 min, int24 max) internal pure returns (int24) { + if (value < min) return min; + if (value > max) return max; + return value; + } + + receive() external payable {} +} diff --git a/test/zap-migrate/ZapMigrateUniswapV3.t.sol b/test/zap-migrate/ZapMigrateUniswapV3.t.sol new file mode 100644 index 0000000..d4200a5 --- /dev/null +++ b/test/zap-migrate/ZapMigrateUniswapV3.t.sol @@ -0,0 +1,775 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import '../Base.t.sol'; + +import 'src/hooks/base/BaseHook.sol'; +import 'src/hooks/base/BaseTickBasedZapMigrateHook.sol'; +import 'src/hooks/zap-migrate/KSZapMigrateUniswapV3Hook.sol'; + +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import 'src/interfaces/uniswapv3/IUniswapV3Factory.sol'; +import 'src/interfaces/uniswapv3/IUniswapV3PM.sol'; +import 'src/interfaces/uniswapv3/IUniswapV3Pool.sol'; +import {TickMath} from 'src/libraries/uniswapv4/TickMath.sol'; + +import './ZapMigrateFuzzParams.sol'; + +contract ZapMigrateUniswapV3Test is BaseTest { + using ArraysHelper for *; + + IUniswapV3PM internal constant PM = IUniswapV3PM(0xC36442b4a4522E871399CD717aBDD847Ab11FE88); + IUniswapV3Pool internal constant POOL = + IUniswapV3Pool(0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640); + address internal constant TOKEN0 = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // USDC + address internal constant TOKEN1 = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // WETH + uint24 internal constant POOL_FEE = 500; + + int24 internal constant TICK_SPACING = 10; + uint256 internal constant FEE_PRECISION = 1_000_000; + + KSZapMigrateUniswapV3Hook internal zapHook; + + struct PositionContext { + address token0; + address token1; + uint24 fee; + int24 tickLower; + int24 tickUpper; + int24 currentTick; + } + + struct SuccessMigrationSetup { + IntentData intentData; + ActionData actionData0; + int24 targetTickForSecondMigration; + uint256 amount0Desired0; + uint256 amount1Desired0; + uint256 amount0Desired1; + uint256 amount1Desired1; + uint24 actionFee01; + uint24 actionFee11; + } + + function _selectFork() public override { + FORK_BLOCK = 22_230_873; + vm.createSelectFork('mainnet', FORK_BLOCK); + } + + function setUp() public override { + super.setUp(); + zapHook = new KSZapMigrateUniswapV3Hook([address(router)].toMemoryArray()); + } + + function testFuzz_ZapMigrateUniswapV3_Success(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + SuccessMigrationSetup memory setup = _prepareSuccessFirstMigration(fuzz, oldNftId, pos); + + deal(pos.token0, address(mockActionContract), setup.amount0Desired0); + deal(pos.token1, address(mockActionContract), setup.amount1Desired0); + + vm.prank(address(forwarder)); + router.delegate(setup.intentData); + _executeDelegated(setup.intentData, setup.actionData0); + + bytes32 intentHash = router.hashTypedIntentData(setup.intentData); + uint256 firstMigratedNftId = zapHook.nftIds(intentHash); + assertTrue(firstMigratedNftId != 0, 'first migrated nft id is not recorded'); + assertTrue(firstMigratedNftId != oldNftId, 'first migrated nft id must differ from old nft id'); + assertEq( + PM.ownerOf(firstMigratedNftId), address(forwarder), 'first migrated nft owner mismatch' + ); + + vm.prank(address(forwarder)); + PM.approve(address(mockActionContract), firstMigratedNftId); + vm.prank(address(forwarder)); + PM.approve(address(router), oldNftId); + + PositionContext memory posBeforeSwap = _positionContext(firstMigratedNftId); + int24 targetTick = _clampInt24( + setup.targetTickForSecondMigration, posBeforeSwap.tickLower, posBeforeSwap.tickUpper + ); + PositionContext memory posAfterSwap = + _movePoolTickToTarget(firstMigratedNftId, posBeforeSwap, targetTick); + + (int24 newTickLower1, int24 newTickUpper1) = + _newTicks(posAfterSwap.currentTick, fuzz.newRangeMultiplier); + ActionData memory actionData1 = _buildActionData( + firstMigratedNftId, + newTickLower1, + newTickUpper1, + address(forwarder), + setup.amount0Desired1, + setup.amount1Desired1, + setup.actionFee01, + setup.actionFee11 + ); + actionData1.nonce = 1; + + deal(posAfterSwap.token0, address(mockActionContract), setup.amount0Desired1); + deal(posAfterSwap.token1, address(mockActionContract), setup.amount1Desired1); + + _executeDelegated(setup.intentData, actionData1); + + uint256 secondMigratedNftId = zapHook.nftIds(intentHash); + + assertTrue(secondMigratedNftId != 0, 'second migrated nft id is not recorded'); + assertTrue( + secondMigratedNftId != firstMigratedNftId, + 'second migrated nft id must differ from first migrated nft id' + ); + assertEq(PM.ownerOf(oldNftId), address(forwarder), 'old nft owner mismatch'); + assertEq( + PM.ownerOf(firstMigratedNftId), address(forwarder), 'first migrated nft owner mismatch' + ); + assertEq( + PM.ownerOf(secondMigratedNftId), address(forwarder), 'second migrated nft owner mismatch' + ); + } + + function testFuzz_Revert_InvalidTokenData(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + uint256 amount0Desired = bound(fuzz.actionAmount0Desireds[0], 1e9, 2000e6); + uint256 amount1Desired = bound(fuzz.actionAmount1Desireds[0], 1e15, 2 ether); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), amount0Desired, amount1Desired, 0, 0 + ); + actionData.erc20Ids = [uint256(0)].toMemoryArray(); + actionData.erc20Amounts = [bound(fuzz.invalidTokenErc20Amount, 0, 1e18)].toMemoryArray(); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseHook.InvalidTokenData.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooLargeDistanceFromTickBoundaries(ZapMigrateFuzzParams memory fuzz) + public + { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + hookData.maxDistanceFromLowerTickBeforeMigration = (pos.currentTick - pos.tickLower) - 1; + hookData.maxDistanceFromUpperTickBeforeMigration = (pos.tickUpper - pos.currentTick) - 1; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = + _buildActionData(oldNftId, newTickLower, newTickUpper, address(forwarder), 1e18, 1e18, 0, 0); + + vm.expectRevert(BaseTickBasedZapMigrateHook.TooLargeDistanceFromTickBoundaries.selector); + vm.prank(address(router)); + zapHook.beforeExecution(bytes32(0), intentData, actionData); + } + + function testFuzz_Revert_InvalidOwner(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + mainAddress, fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + uint256 amount0Desired = bound(fuzz.actionAmount0Desireds[0], 1e9, 40_000e6); + uint256 amount1Desired = bound(fuzz.actionAmount1Desireds[0], 1 ether, 30 ether); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + IntentData memory intentData = _buildIntentData(mainAddress, hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, mainAddress, amount0Desired, amount1Desired, 0, 0 + ); + + deal(pos.token0, address(mockActionContract), amount0Desired); + deal(pos.token1, address(mockActionContract), amount1Desired); + + vm.prank(mainAddress); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.InvalidOwner.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_ExceedMaxFeesPercent(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + uint256 amount0Desired = bound(fuzz.actionAmount0Desireds[0], 1e9, 40_000e6); + uint256 amount1Desired = bound(fuzz.actionAmount1Desireds[0], 1 ether, 30 ether); + uint24 actionFee0 = uint24(bound(fuzz.actionFee0s[0], 1, FEE_PRECISION)); + uint24 actionFee1 = uint24(bound(fuzz.actionFee1s[0], 1, FEE_PRECISION)); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, actionFee0, actionFee1); + hookData.maxFee0 = bound(fuzz.maxFee0, 0, uint256(actionFee0 - 1)); + hookData.maxFee1 = bound(fuzz.maxFee1, 0, uint256(actionFee1 - 1)); + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, + newTickLower, + newTickUpper, + address(forwarder), + amount0Desired, + amount1Desired, + actionFee0, + actionFee1 + ); + + deal(pos.token0, address(mockActionContract), amount0Desired); + deal(pos.token1, address(mockActionContract), amount1Desired); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.ExceedMaxFeesPercent.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooSmallDistanceFromTickBoundaries(ZapMigrateFuzzParams memory fuzz) + public + { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + int24 requiredExtraDistance = int24(uint24(_clamp(fuzz.samplePositionIndex, 1, 10_000))); + hookData.minDistanceFromLowerTickAfterMigration = + (pos.currentTick - newTickLower) + requiredExtraDistance; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = + _buildActionData(oldNftId, newTickLower, newTickUpper, address(forwarder), 1e18, 1e18, 0, 0); + + deal(pos.token0, address(mockActionContract), 1e18); + deal(pos.token1, address(mockActionContract), 1e18); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.TooSmallDistanceFromTickBoundaries.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooSmallTickRangeLength(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + int24 range = newTickUpper - newTickLower; + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + int24 minIncrease = int24(uint24(_clamp(fuzz.invalidTokenErc20Amount, 1, 10_000))); + hookData.minTickRangeLength = range + minIncrease; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = + _buildActionData(oldNftId, newTickLower, newTickUpper, address(forwarder), 1e18, 1e18, 0, 0); + + deal(pos.token0, address(mockActionContract), 1e18); + deal(pos.token1, address(mockActionContract), 1e18); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.TooSmallTickRangeLength.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooLargeTickRangeLength(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + int24 range = newTickUpper - newTickLower; + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + uint256 maxDecrease = uint256(uint24(range - 1)); + int24 decrease = int24(uint24(_clamp(fuzz.samplePositionIndex, 1, maxDecrease))); + hookData.maxTickRangeLength = range - decrease; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = + _buildActionData(oldNftId, newTickLower, newTickUpper, address(forwarder), 1e18, 1e18, 0, 0); + + deal(pos.token0, address(mockActionContract), 1e18); + deal(pos.token1, address(mockActionContract), 1e18); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.TooLargeTickRangeLength.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_InsufficientPositionValue(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + hookData.minValueInToken0 = type(uint128).max + bound(fuzz.minValueInToken0, 1, 1e30); + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = + _buildActionData(oldNftId, newTickLower, newTickUpper, address(forwarder), 1e18, 1e18, 0, 0); + + deal(pos.token0, address(mockActionContract), 1e18); + deal(pos.token1, address(mockActionContract), 1e18); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.InsufficientPositionValue.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_InvalidPoolUniqueId(ZapMigrateFuzzParams memory fuzz) public { + (uint256 index, uint256 nftId, address owner,,, bytes32 poolUniqueId) = + _samplePosition(fuzz.samplePositionIndex); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = _minimalHookData(nftId); + IntentData memory intentData = _buildIntentData(owner, hookData, nftId); + + bytes32 invalidPoolUniqueId = + poolUniqueId ^ bytes32(_clamp(fuzz.invalidTokenErc20Amount, 1, type(uint256).max)); + BaseTickBasedZapMigrateHook.BeforeExecutionData memory beforeData = + BaseTickBasedZapMigrateHook.BeforeExecutionData({ + originalNftId: nftId, + poolUniqueId: invalidPoolUniqueId, + amount0Before: 1, + amount1Before: 1, + balance0Before: 0, + balance1Before: 0, + sqrtPriceX96Before: type(uint160).max, + directionalPositionValue: 1, + direction: true, + additionalData: abi.encode(index) + }); + + vm.prank(address(router)); + vm.expectRevert(BaseTickBasedZapMigrateHook.InvalidPoolUniqueId.selector); + zapHook.afterExecution(bytes32(0), intentData, abi.encode(beforeData), ''); + } + + function testFuzz_Revert_ExceedMaxValueReductionPerAction(ZapMigrateFuzzParams memory fuzz) + public + { + ( + uint256 index, + uint256 nftId, + address owner, + address token0, + address token1, + bytes32 poolUniqueId + ) = _samplePosition(fuzz.samplePositionIndex); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = _minimalHookData(nftId); + hookData.maxValueReductionPerAction = + bound(fuzz.maxValueReductionPerAction, 0, FEE_PRECISION - 1); + uint256 directionalPositionValue = type(uint256).max - bound(fuzz.minValueInToken0, 0, 1e30); + + IntentData memory intentData = _buildIntentData(owner, hookData, nftId); + + BaseTickBasedZapMigrateHook.BeforeExecutionData memory beforeData = + BaseTickBasedZapMigrateHook.BeforeExecutionData({ + originalNftId: nftId, + poolUniqueId: poolUniqueId, + amount0Before: type(uint128).max, + amount1Before: type(uint128).max, + balance0Before: IERC20(token0).balanceOf(address(router)), + balance1Before: IERC20(token1).balanceOf(address(router)), + sqrtPriceX96Before: type(uint160).max, + directionalPositionValue: directionalPositionValue, + direction: true, + additionalData: abi.encode(index) + }); + + vm.prank(address(router)); + vm.expectRevert(BaseTickBasedZapMigrateHook.ExceedMaxValueReductionPerAction.selector); + zapHook.afterExecution(bytes32(0), intentData, abi.encode(beforeData), ''); + } + + function _actionParams(ZapMigrateFuzzParams memory fuzz, uint256 actionIndex) + internal + pure + returns (uint256 amount0Desired, uint256 amount1Desired, uint24 actionFee0, uint24 actionFee1) + { + amount0Desired = bound(fuzz.actionAmount0Desireds[actionIndex], 1e9, 40_000e6); + amount1Desired = bound(fuzz.actionAmount1Desireds[actionIndex], 1 ether, 30 ether); + actionFee0 = uint24(bound(fuzz.actionFee0s[actionIndex], 0, 50_000)); + actionFee1 = uint24(bound(fuzz.actionFee1s[actionIndex], 0, 50_000)); + } + + function _prepareSuccessFirstMigration( + ZapMigrateFuzzParams memory fuzz, + uint256 oldNftId, + PositionContext memory pos + ) internal view returns (SuccessMigrationSetup memory setup) { + uint24 actionFee00; + uint24 actionFee10; + (setup.amount0Desired0, setup.amount1Desired0, actionFee00, actionFee10) = + _actionParams(fuzz, 0); + (int24 newTickLower0, int24 newTickUpper0) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower0, newTickUpper0, actionFee00, actionFee10); + hookData.maxDistanceFromLowerTickBeforeMigration = type(int24).max; + hookData.maxDistanceFromUpperTickBeforeMigration = type(int24).max; + + setup.targetTickForSecondMigration = + _clampInt24(fuzz.newTickAfterSwap, newTickLower0, newTickUpper0); + + (setup.amount0Desired1, setup.amount1Desired1, setup.actionFee01, setup.actionFee11) = + _actionParams(fuzz, 1); + int24 minRange = newTickUpper0 - newTickLower0; + // Keep post-migration min-distance checks satisfiable for the second migration + // regardless of tick alignment after swap and range reconstruction. + hookData.minDistanceFromLowerTickAfterMigration = + _clampInt24(fuzz.minDistanceFromLowerTickAfterMigration, 0, 1); + hookData.minDistanceFromUpperTickAfterMigration = + _clampInt24(fuzz.minDistanceFromUpperTickAfterMigration, 0, 1); + hookData.minTickRangeLength = _clampInt24(fuzz.minTickRangeLength, 0, minRange); + hookData.maxTickRangeLength = _clampInt24(fuzz.maxTickRangeLength, minRange, type(int24).max); + uint256 actionFee0Max = actionFee00 > setup.actionFee01 ? actionFee00 : setup.actionFee01; + uint256 actionFee1Max = actionFee10 > setup.actionFee11 ? actionFee10 : setup.actionFee11; + hookData.maxFee0 = bound(fuzz.maxFee0, actionFee0Max, FEE_PRECISION); + hookData.maxFee1 = bound(fuzz.maxFee1, actionFee1Max, FEE_PRECISION); + + setup.intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + setup.actionData0 = _buildActionData( + oldNftId, + newTickLower0, + newTickUpper0, + address(forwarder), + setup.amount0Desired0, + setup.amount1Desired0, + actionFee00, + actionFee10 + ); + } + + function _executeDelegated(IntentData memory intentData, ActionData memory actionData) internal { + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + vm.prank(caller); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function _currentTick() internal view returns (int24 tick) { + (, tick,,,,,) = POOL.slot0(); + } + + function _movePoolTickToTarget( + uint256 nftId, + PositionContext memory posBeforeSwap, + int24 targetTick + ) internal returns (PositionContext memory posAfterSwap) { + _movePoolPriceToTick(posBeforeSwap.currentTick, targetTick); + posAfterSwap = _positionContext(nftId); + } + + function _movePoolPriceToTick(int24 currentTick, int24 targetTick) internal { + if (targetTick == currentTick) return; + + bool zeroForOne = targetTick < currentTick; + uint160 sqrtPriceLimitX96 = TickMath.getSqrtRatioAtTick(targetTick); + + deal(zeroForOne ? TOKEN0 : TOKEN1, address(this), type(uint128).max); + + POOL.swap( + address(this), + zeroForOne, + int256(type(int128).max), + sqrtPriceLimitX96, + abi.encode(TOKEN0, TOKEN1) + ); + } + + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) + external + { + require(msg.sender == address(POOL), 'invalid pool caller'); + (address token0, address token1) = abi.decode(data, (address, address)); + if (amount0Delta > 0) { + IERC20(token0).transfer(msg.sender, uint256(amount0Delta)); + } + if (amount1Delta > 0) { + IERC20(token1).transfer(msg.sender, uint256(amount1Delta)); + } + } + + function _successHookData( + ZapMigrateFuzzParams memory fuzz, + uint256 nftId, + PositionContext memory pos, + int24 newTickLower, + int24 newTickUpper, + uint24 actionFee0, + uint24 actionFee1 + ) internal pure returns (BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData) { + int24 oldDistLower = pos.currentTick - pos.tickLower; + int24 oldDistUpper = pos.tickUpper - pos.currentTick; + int24 newDistLower = pos.currentTick - newTickLower; + int24 newDistUpper = newTickUpper - pos.currentTick; + int24 range = newTickUpper - newTickLower; + + hookData.nftId = nftId; + hookData.minValueInToken0 = bound(fuzz.minValueInToken0, 0, 1); + hookData.minValueInToken1 = bound(fuzz.minValueInToken1, 0, 1); + hookData.maxValueReductionPerAction = FEE_PRECISION; + hookData.maxDistanceFromLowerTickBeforeMigration = + _clampInt24(fuzz.maxDistanceFromLowerTickBeforeMigration, oldDistLower, type(int24).max); + hookData.maxDistanceFromUpperTickBeforeMigration = + _clampInt24(fuzz.maxDistanceFromUpperTickBeforeMigration, oldDistUpper, type(int24).max); + hookData.minDistanceFromLowerTickAfterMigration = + _clampInt24(fuzz.minDistanceFromLowerTickAfterMigration, 0, newDistLower); + hookData.minDistanceFromUpperTickAfterMigration = + _clampInt24(fuzz.minDistanceFromUpperTickAfterMigration, 0, newDistUpper); + hookData.minTickRangeLength = _clampInt24(fuzz.minTickRangeLength, 0, range); + hookData.maxTickRangeLength = _clampInt24(fuzz.maxTickRangeLength, range, type(int24).max); + hookData.maxFee0 = bound(fuzz.maxFee0, actionFee0, FEE_PRECISION); + hookData.maxFee1 = bound(fuzz.maxFee1, actionFee1, FEE_PRECISION); + } + + function _positionContext(uint256 nftId) internal view returns (PositionContext memory pos) { + (,, pos.token0, pos.token1, pos.fee, pos.tickLower, pos.tickUpper,,,,,) = PM.positions(nftId); + (, pos.currentTick,,,,,) = POOL.slot0(); + } + + function _newTicks(int24 currentTick, uint8 rangeMultiplier) + internal + pure + returns (int24 lower, int24 upper) + { + uint8 m = uint8(_clamp(rangeMultiplier, 2, 120)); + if (m % 2 == 1) { + m = m == 120 ? 119 : m + 1; + } + int24 base = currentTick / TICK_SPACING * TICK_SPACING; + if (currentTick < 0 && currentTick % TICK_SPACING != 0) { + base -= TICK_SPACING; + } + + int24 range = int24(uint24(m)) * TICK_SPACING; + lower = base - range / 2; + upper = lower + range; + + if (upper <= currentTick) { + upper += TICK_SPACING; + lower += TICK_SPACING; + } + if (lower >= currentTick) { + lower -= TICK_SPACING; + upper -= TICK_SPACING; + } + } + + function _mintFreshPosition( + address owner, + uint8 mintRangeMultiplier, + uint256 mintAmount0DesiredRaw, + uint256 mintAmount1DesiredRaw + ) internal returns (uint256 nftId, PositionContext memory pos) { + (, int24 currentTick,,,,,) = POOL.slot0(); + (int24 tickLower, int24 tickUpper) = + _newTicks(currentTick, uint8(_clamp(mintRangeMultiplier, 40, 120))); + + uint256 mintAmount0Desired = bound(mintAmount0DesiredRaw, 30_000e6, 40_000e6); + uint256 mintAmount1Desired = bound(mintAmount1DesiredRaw, 18 ether, 22 ether); + + address minter = mainAddress; + deal(TOKEN0, minter, mintAmount0Desired); + deal(TOKEN1, minter, mintAmount1Desired); + + vm.startPrank(minter); + IERC20(TOKEN0).approve(address(PM), type(uint256).max); + IERC20(TOKEN1).approve(address(PM), type(uint256).max); + (nftId,,,) = PM.mint( + IUniswapV3PM.MintParams({ + token0: TOKEN0, + token1: TOKEN1, + fee: POOL_FEE, + tickLower: tickLower, + tickUpper: tickUpper, + amount0Desired: mintAmount0Desired, + amount1Desired: mintAmount1Desired, + amount0Min: 0, + amount1Min: 0, + recipient: minter, + deadline: block.timestamp + 1 days + }) + ); + vm.stopPrank(); + + if (owner != minter) { + vm.prank(minter); + PM.transferFrom(minter, owner, nftId); + } + + vm.prank(owner); + PM.approve(address(router), nftId); + + pos = _positionContext(nftId); + } + + function _buildIntentData( + address intentMainAddress, + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData, + uint256 nftId + ) internal view returns (IntentData memory intentData) { + IntentCoreData memory coreData = IntentCoreData({ + mainAddress: intentMainAddress, + signatureVerifier: address(0), + delegatedKey: delegatedPublicKey, + actionContracts: [address(mockActionContract)].toMemoryArray(), + actionSelectors: [MockActionContract.zapMigrateUniswapV3.selector].toMemoryArray(), + hook: address(zapHook), + hookIntentData: abi.encode(hookData) + }); + + TokenData memory tokenData; + tokenData.erc721Data = new ERC721Data[](1); + tokenData.erc721Data[0] = ERC721Data({token: address(PM), tokenId: nftId, permitData: ''}); + + intentData = IntentData({coreData: coreData, tokenData: tokenData, extraData: ''}); + } + + function _buildActionData( + uint256 oldNftId, + int24 newTickLower, + int24 newTickUpper, + address newNftRecipient, + uint256 amount0Desired, + uint256 amount1Desired, + uint24 actionFee0, + uint24 actionFee1 + ) internal view returns (ActionData memory actionData) { + FeeInfo memory feeInfo; + feeInfo.protocolRecipient = protocolRecipient; + feeInfo.partnerFeeConfigs = new FeeConfig[][](2); + feeInfo.partnerFeeConfigs[0] = new FeeConfig[](0); + feeInfo.partnerFeeConfigs[1] = new FeeConfig[](0); + + MockActionContract.ZapMigrateUniswapV3Params memory params = + MockActionContract.ZapMigrateUniswapV3Params({ + pm: PM, + oldTokenId: oldNftId, + newTickLower: newTickLower, + newTickUpper: newTickUpper, + router: address(router), + mainAddress: newNftRecipient, + amountDesireds: [amount0Desired, amount1Desired].toMemoryArray(), + fees: [uint256(actionFee0), uint256(actionFee1)].toMemoryArray() + }); + + actionData = ActionData({ + erc20Ids: new uint256[](0), + erc20Amounts: new uint256[](0), + erc721Ids: [uint256(0)].toMemoryArray(), + feeInfo: feeInfo, + approvalFlags: type(uint256).max, + actionSelectorId: 0, + actionCalldata: abi.encode(params), + hookActionData: '', + extraData: '', + deadline: block.timestamp + 1 days, + nonce: 0 + }); + } + + function _minimalHookData(uint256 nftId) + internal + pure + returns (BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData) + { + hookData.nftId = nftId; + hookData.maxDistanceFromLowerTickBeforeMigration = type(int24).max; + hookData.maxDistanceFromUpperTickBeforeMigration = type(int24).max; + hookData.minDistanceFromLowerTickAfterMigration = type(int24).min; + hookData.minDistanceFromUpperTickAfterMigration = type(int24).min; + hookData.maxTickRangeLength = type(int24).max; + hookData.maxValueReductionPerAction = FEE_PRECISION; + hookData.maxFee0 = FEE_PRECISION; + hookData.maxFee1 = FEE_PRECISION; + } + + function _samplePosition(uint256 sampleIndex) + internal + view + returns ( + uint256 index, + uint256 nftId, + address owner, + address token0, + address token1, + bytes32 poolUniqueId + ) + { + uint256 total = PM.totalSupply(); + index = _clamp(sampleIndex, 0, total - 1); + nftId = PM.tokenByIndex(index); + owner = PM.ownerOf(nftId); + uint24 fee; + (,, token0, token1, fee,,,,,,,) = PM.positions(nftId); + + IUniswapV3Pool pool = + IUniswapV3Pool(IUniswapV3Factory(PM.factory()).getPool(token0, token1, fee)); + poolUniqueId = bytes32(uint256(uint160(address(pool)))); + } + + function _clamp(uint256 value, uint256 min, uint256 max) internal pure returns (uint256) { + if (value < min) return min; + if (value > max) return max; + return value; + } + + function _clampInt24(int24 value, int24 min, int24 max) internal pure returns (int24) { + if (value < min) return min; + if (value > max) return max; + return value; + } +} diff --git a/test/zap-migrate/ZapMigrateUniswapV4.t.sol b/test/zap-migrate/ZapMigrateUniswapV4.t.sol new file mode 100644 index 0000000..9bcffde --- /dev/null +++ b/test/zap-migrate/ZapMigrateUniswapV4.t.sol @@ -0,0 +1,810 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.0; + +import '../Base.t.sol'; + +import 'src/hooks/base/BaseHook.sol'; +import 'src/hooks/base/BaseTickBasedZapMigrateHook.sol'; +import { + KSZapMigrateUniswapV3Hook as KSZapMigrateUniswapV4Hook +} from 'src/hooks/zap-migrate/KSZapMigrateUniswapV4Hook.sol'; + +import {IAllowanceTransfer} from 'ks-common-sc/src/interfaces/IAllowanceTransfer.sol'; +import {TokenHelper} from 'ks-common-sc/src/libraries/token/TokenHelper.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {IPoolManager} from 'src/interfaces/uniswapv4/IPoolManager.sol'; +import {IPositionManager} from 'src/interfaces/uniswapv4/IPositionManager.sol'; +import {Actions, BalanceDelta, PoolKey, SwapParams} from 'src/interfaces/uniswapv4/Types.sol'; +import {LiquidityAmounts} from 'src/libraries/uniswapv4/LiquidityAmounts.sol'; +import {StateLibrary} from 'src/libraries/uniswapv4/StateLibrary.sol'; +import {TickMath} from 'src/libraries/uniswapv4/TickMath.sol'; + +import './ZapMigrateFuzzParams.sol'; + +contract ZapMigrateUniswapV4Test is BaseTest { + using ArraysHelper for *; + using StateLibrary for IPoolManager; + using TokenHelper for address; + + IPositionManager internal constant PM = + IPositionManager(0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e); + + address internal constant TOKEN0 = address(0); + address internal constant TOKEN1 = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + uint24 internal constant POOL_FEE = 3000; + int24 internal constant TICK_SPACING = 60; + address internal constant POOL_HOOKS = address(0); + address internal constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + uint256 internal constant FEE_PRECISION = 1_000_000; + + KSZapMigrateUniswapV4Hook internal zapHook; + IPoolManager internal poolManager; + + struct PositionContext { + address token0; + address token1; + int24 tickLower; + int24 tickUpper; + int24 currentTick; + bytes32 poolUniqueId; + } + + struct SwapUnlockData { + int24 targetTick; + bool zeroForOne; + } + + struct SuccessMigrationSetup { + IntentData intentData; + ActionData actionData0; + int24 targetTickForSecondMigration; + uint256 amount0Desired0; + uint256 amount1Desired0; + uint256 amount0Desired1; + uint256 amount1Desired1; + uint24 actionFee01; + uint24 actionFee11; + } + + function _selectFork() public override { + FORK_BLOCK = 24_732_325; + vm.createSelectFork('mainnet', FORK_BLOCK); + } + + function setUp() public override { + super.setUp(); + poolManager = PM.poolManager(); + zapHook = new KSZapMigrateUniswapV4Hook([address(router)].toMemoryArray()); + } + + function testFuzz_ZapMigrateUniswapV4_Success(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + SuccessMigrationSetup memory setup = _prepareSuccessFirstMigration(fuzz, oldNftId, pos); + + deal(address(mockActionContract), setup.amount0Desired0); + deal(pos.token1, address(mockActionContract), setup.amount1Desired0); + + vm.prank(address(forwarder)); + router.delegate(setup.intentData); + _executeDelegated(setup.intentData, setup.actionData0); + + bytes32 intentHash = router.hashTypedIntentData(setup.intentData); + uint256 firstMigratedNftId = zapHook.nftIds(intentHash); + assertTrue(firstMigratedNftId != 0, 'first migrated nft id is not recorded'); + assertTrue(firstMigratedNftId != oldNftId, 'first migrated nft id must differ from old nft id'); + assertEq( + PM.ownerOf(firstMigratedNftId), address(forwarder), 'first migrated nft owner mismatch' + ); + + vm.prank(address(forwarder)); + PM.approve(address(mockActionContract), firstMigratedNftId); + vm.prank(address(forwarder)); + PM.approve(address(router), oldNftId); + + PositionContext memory posBeforeSwap = _positionContext(firstMigratedNftId); + int24 targetTick = _clampInt24( + setup.targetTickForSecondMigration, posBeforeSwap.tickLower, posBeforeSwap.tickUpper + ); + PositionContext memory posAfterSwap = + _movePoolTickToTarget(firstMigratedNftId, posBeforeSwap, targetTick); + + (int24 newTickLower1, int24 newTickUpper1) = + _newTicks(posAfterSwap.currentTick, fuzz.newRangeMultiplier); + ActionData memory actionData1 = _buildActionData( + firstMigratedNftId, + newTickLower1, + newTickUpper1, + address(forwarder), + setup.amount0Desired1, + setup.amount1Desired1, + setup.actionFee01, + setup.actionFee11 + ); + actionData1.nonce = 1; + + deal(address(mockActionContract), setup.amount0Desired1); + deal(posAfterSwap.token1, address(mockActionContract), setup.amount1Desired1); + + _executeDelegated(setup.intentData, actionData1); + + uint256 secondMigratedNftId = zapHook.nftIds(intentHash); + + assertTrue(secondMigratedNftId != 0, 'second migrated nft id is not recorded'); + assertTrue( + secondMigratedNftId != firstMigratedNftId, + 'second migrated nft id must differ from first migrated nft id' + ); + assertEq(PM.ownerOf(oldNftId), address(forwarder), 'old nft owner mismatch'); + assertEq( + PM.ownerOf(firstMigratedNftId), address(forwarder), 'first migrated nft owner mismatch' + ); + assertEq( + PM.ownerOf(secondMigratedNftId), address(forwarder), 'second migrated nft owner mismatch' + ); + } + + function testFuzz_Revert_InvalidTokenData(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + uint256 amount0Desired = bound(fuzz.actionAmount0Desireds[0], 0.01 ether, 1 ether); + uint256 amount1Desired = bound(fuzz.actionAmount1Desireds[0], 100e6, 2000e6); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), amount0Desired, amount1Desired, 0, 0 + ); + actionData.erc20Ids = [uint256(0)].toMemoryArray(); + actionData.erc20Amounts = [bound(fuzz.invalidTokenErc20Amount, 0, 1e18)].toMemoryArray(); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseHook.InvalidTokenData.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooLargeDistanceFromTickBoundaries(ZapMigrateFuzzParams memory fuzz) + public + { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + hookData.maxDistanceFromLowerTickBeforeMigration = (pos.currentTick - pos.tickLower) - 1; + hookData.maxDistanceFromUpperTickBeforeMigration = (pos.tickUpper - pos.currentTick) - 1; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + vm.expectRevert(BaseTickBasedZapMigrateHook.TooLargeDistanceFromTickBoundaries.selector); + vm.prank(address(router)); + zapHook.beforeExecution(bytes32(0), intentData, actionData); + } + + function testFuzz_Revert_InvalidOwner(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + mainAddress, fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + uint256 amount0Desired = bound(fuzz.actionAmount0Desireds[0], 0.1 ether, 5 ether); + uint256 amount1Desired = bound(fuzz.actionAmount1Desireds[0], 100e6, 20_000e6); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + IntentData memory intentData = _buildIntentData(mainAddress, hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, mainAddress, amount0Desired, amount1Desired, 0, 0 + ); + + deal(address(mockActionContract), amount0Desired); + deal(pos.token1, address(mockActionContract), amount1Desired); + + vm.prank(mainAddress); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.InvalidOwner.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_ExceedMaxFeesPercent(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + uint256 amount0Desired = bound(fuzz.actionAmount0Desireds[0], 0.1 ether, 5 ether); + uint256 amount1Desired = bound(fuzz.actionAmount1Desireds[0], 100e6, 20_000e6); + uint24 actionFee0 = uint24(bound(fuzz.actionFee0s[0], 1, FEE_PRECISION)); + uint24 actionFee1 = uint24(bound(fuzz.actionFee1s[0], 1, FEE_PRECISION)); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, actionFee0, actionFee1); + hookData.maxFee0 = bound(fuzz.maxFee0, 0, uint256(actionFee0 - 1)); + hookData.maxFee1 = bound(fuzz.maxFee1, 0, uint256(actionFee1 - 1)); + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, + newTickLower, + newTickUpper, + address(forwarder), + amount0Desired, + amount1Desired, + actionFee0, + actionFee1 + ); + + deal(address(mockActionContract), amount0Desired); + deal(pos.token1, address(mockActionContract), amount1Desired); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.ExceedMaxFeesPercent.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooSmallDistanceFromTickBoundaries(ZapMigrateFuzzParams memory fuzz) + public + { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + int24 requiredExtraDistance = int24(uint24(_clamp(fuzz.samplePositionIndex, 1, 10_000))); + hookData.minDistanceFromLowerTickAfterMigration = + (pos.currentTick - newTickLower) + requiredExtraDistance; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + deal(address(mockActionContract), 0.1 ether); + deal(pos.token1, address(mockActionContract), 500e6); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.TooSmallDistanceFromTickBoundaries.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooSmallTickRangeLength(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + int24 range = newTickUpper - newTickLower; + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + int24 minIncrease = int24(uint24(_clamp(fuzz.invalidTokenErc20Amount, 1, 10_000))); + hookData.minTickRangeLength = range + minIncrease; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + deal(address(mockActionContract), 0.1 ether); + deal(pos.token1, address(mockActionContract), 500e6); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.TooSmallTickRangeLength.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_TooLargeTickRangeLength(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + int24 range = newTickUpper - newTickLower; + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + uint256 maxDecrease = uint256(uint24(range - 1)); + int24 decrease = int24(uint24(_clamp(fuzz.samplePositionIndex, 1, maxDecrease))); + hookData.maxTickRangeLength = range - decrease; + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + deal(address(mockActionContract), 0.1 ether); + deal(pos.token1, address(mockActionContract), 500e6); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.TooLargeTickRangeLength.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_InsufficientPositionValue(ZapMigrateFuzzParams memory fuzz) public { + (uint256 oldNftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + (int24 newTickLower, int24 newTickUpper) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower, newTickUpper, 0, 0); + hookData.minValueInToken0 = type(uint128).max + bound(fuzz.minValueInToken0, 1, 1e30); + + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + ActionData memory actionData = _buildActionData( + oldNftId, newTickLower, newTickUpper, address(forwarder), 0.1 ether, 500e6, 0, 0 + ); + + deal(address(mockActionContract), 0.1 ether); + deal(pos.token1, address(mockActionContract), 500e6); + + vm.prank(address(forwarder)); + router.delegate(intentData); + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + + vm.prank(caller); + vm.expectRevert(BaseTickBasedZapMigrateHook.InsufficientPositionValue.selector); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function testFuzz_Revert_InvalidPoolUniqueId(ZapMigrateFuzzParams memory fuzz) public { + (uint256 nftId, PositionContext memory pos) = _mintFreshPosition( + address(forwarder), fuzz.mintRangeMultiplier, fuzz.mintAmount0Desired, fuzz.mintAmount1Desired + ); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = _minimalHookData(nftId); + IntentData memory intentData = _buildIntentData(address(forwarder), hookData, nftId); + + bytes32 invalidPoolUniqueId = + pos.poolUniqueId ^ bytes32(_clamp(fuzz.invalidTokenErc20Amount, 1, type(uint256).max)); + BaseTickBasedZapMigrateHook.BeforeExecutionData memory beforeData = + BaseTickBasedZapMigrateHook.BeforeExecutionData({ + originalNftId: nftId, + poolUniqueId: invalidPoolUniqueId, + amount0Before: 1, + amount1Before: 1, + balance0Before: 0, + balance1Before: 0, + sqrtPriceX96Before: 0, + directionalPositionValue: 1, + direction: true, + additionalData: '' + }); + + vm.prank(address(router)); + vm.expectRevert(BaseTickBasedZapMigrateHook.InvalidPoolUniqueId.selector); + zapHook.afterExecution(bytes32(0), intentData, abi.encode(beforeData), ''); + } + + function testFuzz_Revert_ExceedMaxValueReductionPerAction(ZapMigrateFuzzParams memory fuzz) + public + { + uint256 nftId = PM.nextTokenId() - 1; + PositionContext memory pos = _positionContext(nftId); + address owner = PM.ownerOf(nftId); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = _minimalHookData(nftId); + hookData.maxValueReductionPerAction = + bound(fuzz.maxValueReductionPerAction, 0, FEE_PRECISION - 1); + uint256 directionalPositionValue = type(uint256).max - bound(fuzz.minValueInToken0, 0, 1e30); + IntentData memory intentData = _buildIntentData(owner, hookData, nftId); + + BaseTickBasedZapMigrateHook.BeforeExecutionData memory beforeData = + BaseTickBasedZapMigrateHook.BeforeExecutionData({ + originalNftId: nftId, + poolUniqueId: pos.poolUniqueId, + amount0Before: type(uint128).max, + amount1Before: type(uint128).max, + balance0Before: pos.token0.balanceOf(address(router)), + balance1Before: pos.token1.balanceOf(address(router)), + sqrtPriceX96Before: 0, + directionalPositionValue: directionalPositionValue, + direction: true, + additionalData: '' + }); + + vm.prank(address(router)); + vm.expectRevert(BaseTickBasedZapMigrateHook.InvalidPoolUniqueId.selector); + zapHook.afterExecution(bytes32(0), intentData, abi.encode(beforeData), ''); + } + + function _actionParams(ZapMigrateFuzzParams memory fuzz, uint256 actionIndex) + internal + pure + returns (uint256 amount0Desired, uint256 amount1Desired, uint24 actionFee0, uint24 actionFee1) + { + amount0Desired = bound(fuzz.actionAmount0Desireds[actionIndex], 0.1 ether, 5 ether); + amount1Desired = bound(fuzz.actionAmount1Desireds[actionIndex], 100e6, 20_000e6); + actionFee0 = uint24(bound(fuzz.actionFee0s[actionIndex], 0, 50_000)); + actionFee1 = uint24(bound(fuzz.actionFee1s[actionIndex], 0, 50_000)); + } + + function _prepareSuccessFirstMigration( + ZapMigrateFuzzParams memory fuzz, + uint256 oldNftId, + PositionContext memory pos + ) internal view returns (SuccessMigrationSetup memory setup) { + uint24 actionFee00; + uint24 actionFee10; + (setup.amount0Desired0, setup.amount1Desired0, actionFee00, actionFee10) = + _actionParams(fuzz, 0); + (int24 newTickLower0, int24 newTickUpper0) = _newTicks(pos.currentTick, fuzz.newRangeMultiplier); + + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData = + _successHookData(fuzz, oldNftId, pos, newTickLower0, newTickUpper0, actionFee00, actionFee10); + hookData.maxDistanceFromLowerTickBeforeMigration = type(int24).max; + hookData.maxDistanceFromUpperTickBeforeMigration = type(int24).max; + + setup.targetTickForSecondMigration = + _clampInt24(fuzz.newTickAfterSwap, newTickLower0, newTickUpper0); + + (setup.amount0Desired1, setup.amount1Desired1, setup.actionFee01, setup.actionFee11) = + _actionParams(fuzz, 1); + int24 minRange = newTickUpper0 - newTickLower0; + // Keep post-migration min-distance checks satisfiable for the second migration + // regardless of tick alignment after swap and range reconstruction. + hookData.minDistanceFromLowerTickAfterMigration = + _clampInt24(fuzz.minDistanceFromLowerTickAfterMigration, 0, 1); + hookData.minDistanceFromUpperTickAfterMigration = + _clampInt24(fuzz.minDistanceFromUpperTickAfterMigration, 0, 1); + hookData.minTickRangeLength = _clampInt24(fuzz.minTickRangeLength, 0, minRange); + hookData.maxTickRangeLength = _clampInt24(fuzz.maxTickRangeLength, minRange, type(int24).max); + uint256 actionFee0Max = actionFee00 > setup.actionFee01 ? actionFee00 : setup.actionFee01; + uint256 actionFee1Max = actionFee10 > setup.actionFee11 ? actionFee10 : setup.actionFee11; + hookData.maxFee0 = bound(fuzz.maxFee0, actionFee0Max, FEE_PRECISION); + hookData.maxFee1 = bound(fuzz.maxFee1, actionFee1Max, FEE_PRECISION); + + setup.intentData = _buildIntentData(address(forwarder), hookData, oldNftId); + setup.actionData0 = _buildActionData( + oldNftId, + newTickLower0, + newTickUpper0, + address(forwarder), + setup.amount0Desired0, + setup.amount1Desired0, + actionFee00, + actionFee10 + ); + } + + function _executeDelegated(IntentData memory intentData, ActionData memory actionData) internal { + (address caller, bytes memory dkSignature, bytes memory gdSignature) = + _getCallerAndSignatures(0, intentData, actionData); + vm.prank(caller); + router.execute(intentData, dkSignature, guardian, gdSignature, actionData); + } + + function _movePoolTickToTarget( + uint256 nftId, + PositionContext memory posBeforeSwap, + int24 targetTick + ) internal returns (PositionContext memory posAfterSwap) { + _movePoolPriceToTick(posBeforeSwap.currentTick, targetTick); + posAfterSwap = _positionContext(nftId); + } + + function _movePoolPriceToTick(int24 currentTick, int24 targetTick) internal { + if (targetTick == currentTick) return; + bool zeroForOne = targetTick < currentTick; + vm.deal(address(this), 1000 ether); + deal(TOKEN1, address(this), type(uint128).max); + poolManager.unlock(abi.encode(SwapUnlockData({targetTick: targetTick, zeroForOne: zeroForOne}))); + } + + function unlockCallback(bytes calldata data) external returns (bytes memory) { + require(msg.sender == address(poolManager), 'invalid pool manager'); + SwapUnlockData memory swapData = abi.decode(data, (SwapUnlockData)); + BalanceDelta delta = poolManager.swap( + _poolKey(), + SwapParams({ + zeroForOne: swapData.zeroForOne, + amountSpecified: -int256(type(int128).max), + sqrtPriceLimitX96: TickMath.getSqrtRatioAtTick(swapData.targetTick) + }), + bytes('') + ); + + int128 amount0 = _amount0(delta); + int128 amount1 = _amount1(delta); + if (amount0 < 0) _settleCurrency(TOKEN0, uint128(-amount0)); + if (amount1 < 0) _settleCurrency(TOKEN1, uint128(-amount1)); + if (amount0 > 0) poolManager.take(TOKEN0, address(this), uint128(amount0)); + if (amount1 > 0) poolManager.take(TOKEN1, address(this), uint128(amount1)); + return bytes(''); + } + + function _settleCurrency(address currency, uint256 amount) internal { + if (amount == 0) return; + if (currency.isNative()) { + poolManager.settle{value: amount}(); + return; + } + poolManager.sync(currency); + currency.safeTransfer(address(poolManager), amount); + poolManager.settle(); + } + + function _amount0(BalanceDelta delta) internal pure returns (int128 amount0) { + assembly ('memory-safe') { + amount0 := sar(128, delta) + } + } + + function _amount1(BalanceDelta delta) internal pure returns (int128 amount1) { + amount1 = int128(uint128(uint256(BalanceDelta.unwrap(delta)))); + } + + function _successHookData( + ZapMigrateFuzzParams memory fuzz, + uint256 nftId, + PositionContext memory pos, + int24 newTickLower, + int24 newTickUpper, + uint24 actionFee0, + uint24 actionFee1 + ) internal pure returns (BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData) { + int24 oldDistLower = pos.currentTick - pos.tickLower; + int24 oldDistUpper = pos.tickUpper - pos.currentTick; + int24 newDistLower = pos.currentTick - newTickLower; + int24 newDistUpper = newTickUpper - pos.currentTick; + int24 range = newTickUpper - newTickLower; + + hookData.nftId = nftId; + hookData.minValueInToken0 = bound(fuzz.minValueInToken0, 0, 1); + hookData.minValueInToken1 = bound(fuzz.minValueInToken1, 0, 1); + hookData.maxValueReductionPerAction = FEE_PRECISION; + hookData.maxDistanceFromLowerTickBeforeMigration = + _clampInt24(fuzz.maxDistanceFromLowerTickBeforeMigration, oldDistLower, type(int24).max); + hookData.maxDistanceFromUpperTickBeforeMigration = + _clampInt24(fuzz.maxDistanceFromUpperTickBeforeMigration, oldDistUpper, type(int24).max); + hookData.minDistanceFromLowerTickAfterMigration = + _clampInt24(fuzz.minDistanceFromLowerTickAfterMigration, 0, newDistLower); + hookData.minDistanceFromUpperTickAfterMigration = + _clampInt24(fuzz.minDistanceFromUpperTickAfterMigration, 0, newDistUpper); + hookData.minTickRangeLength = _clampInt24(fuzz.minTickRangeLength, 0, range); + hookData.maxTickRangeLength = _clampInt24(fuzz.maxTickRangeLength, range, type(int24).max); + hookData.maxFee0 = bound(fuzz.maxFee0, actionFee0, FEE_PRECISION); + hookData.maxFee1 = bound(fuzz.maxFee1, actionFee1, FEE_PRECISION); + } + + function _positionContext(uint256 nftId) internal view returns (PositionContext memory pos) { + (PoolKey memory poolKey, uint256 positionInfo) = PM.getPoolAndPositionInfo(nftId); + pos.token0 = poolKey.currency0; + pos.token1 = poolKey.currency1; + pos.poolUniqueId = StateLibrary.getPoolId(poolKey); + (pos.tickLower, pos.tickUpper) = StateLibrary.getTickRange(positionInfo); + (, pos.currentTick,,) = poolManager.getSlot0(pos.poolUniqueId); + } + + function _newTicks(int24 currentTick, uint8 rangeMultiplier) + internal + pure + returns (int24 lower, int24 upper) + { + uint8 m = uint8(_clamp(rangeMultiplier, 2, 120)); + if (m % 2 == 1) { + m = m == 120 ? 119 : m + 1; + } + int24 base = currentTick / TICK_SPACING * TICK_SPACING; + if (currentTick < 0 && currentTick % TICK_SPACING != 0) { + base -= TICK_SPACING; + } + + int24 range = int24(uint24(m)) * TICK_SPACING; + lower = base - range / 2; + upper = lower + range; + + if (upper <= currentTick) { + upper += TICK_SPACING; + lower += TICK_SPACING; + } + if (lower >= currentTick) { + lower -= TICK_SPACING; + upper -= TICK_SPACING; + } + } + + function _mintFreshPosition( + address owner, + uint8 mintRangeMultiplier, + uint256 mintAmount0DesiredRaw, + uint256 mintAmount1DesiredRaw + ) internal returns (uint256 nftId, PositionContext memory pos) { + bytes32 poolId = StateLibrary.getPoolId(_poolKey()); + (uint160 sqrtPriceX96, int24 currentTick,,) = poolManager.getSlot0(poolId); + (int24 tickLower, int24 tickUpper) = + _newTicks(currentTick, uint8(_clamp(mintRangeMultiplier, 40, 120))); + + uint256 mintAmount0Desired = bound(mintAmount0DesiredRaw, 0.5 ether, 3 ether); + uint256 mintAmount1Desired = bound(mintAmount1DesiredRaw, 1000e6, 20_000e6); + uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtRatioAtTick(tickLower), + TickMath.getSqrtRatioAtTick(tickUpper), + mintAmount0Desired, + mintAmount1Desired + ); + + address minter = address(this); + vm.deal(minter, mintAmount0Desired + 1 ether); + deal(TOKEN1, minter, mintAmount1Desired); + + TOKEN1.safeApprove(PERMIT2, type(uint256).max); + IAllowanceTransfer(PERMIT2).approve(TOKEN1, address(PM), type(uint160).max, type(uint48).max); + + bytes memory actions = new bytes(2); + bytes[] memory params = new bytes[](2); + actions[0] = bytes1(uint8(Actions.MINT_POSITION)); + params[0] = abi.encode( + _poolKey(), + tickLower, + tickUpper, + uint256(liquidity), + mintAmount0Desired, + mintAmount1Desired, + minter, + bytes('') + ); + actions[1] = bytes1(uint8(Actions.SETTLE_PAIR)); + params[1] = abi.encode(TOKEN0, TOKEN1); + + PM.modifyLiquidities{value: mintAmount0Desired}(abi.encode(actions, params), type(uint256).max); + nftId = PM.nextTokenId() - 1; + + if (owner != minter) { + PM.transferFrom(minter, owner, nftId); + } + + vm.prank(owner); + PM.approve(address(router), nftId); + + pos = _positionContext(nftId); + } + + function _buildIntentData( + address intentMainAddress, + BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData, + uint256 nftId + ) internal view returns (IntentData memory intentData) { + IntentCoreData memory coreData = IntentCoreData({ + mainAddress: intentMainAddress, + signatureVerifier: address(0), + delegatedKey: delegatedPublicKey, + actionContracts: [address(mockActionContract)].toMemoryArray(), + actionSelectors: [MockActionContract.zapMigrateUniswapV4.selector].toMemoryArray(), + hook: address(zapHook), + hookIntentData: abi.encode(hookData) + }); + + TokenData memory tokenData; + tokenData.erc721Data = new ERC721Data[](1); + tokenData.erc721Data[0] = ERC721Data({token: address(PM), tokenId: nftId, permitData: ''}); + + intentData = IntentData({coreData: coreData, tokenData: tokenData, extraData: ''}); + } + + function _buildActionData( + uint256 oldNftId, + int24 newTickLower, + int24 newTickUpper, + address newNftRecipient, + uint256 amount0Desired, + uint256 amount1Desired, + uint24 actionFee0, + uint24 actionFee1 + ) internal view returns (ActionData memory actionData) { + FeeInfo memory feeInfo; + feeInfo.protocolRecipient = protocolRecipient; + feeInfo.partnerFeeConfigs = new FeeConfig[][](2); + feeInfo.partnerFeeConfigs[0] = new FeeConfig[](0); + feeInfo.partnerFeeConfigs[1] = new FeeConfig[](0); + + MockActionContract.ZapMigrateUniswapV4Params memory params = + MockActionContract.ZapMigrateUniswapV4Params({ + pm: PM, + oldTokenId: oldNftId, + newTickLower: newTickLower, + newTickUpper: newTickUpper, + router: address(router), + mainAddress: newNftRecipient, + amountDesireds: [amount0Desired, amount1Desired].toMemoryArray(), + fees: [uint256(actionFee0), uint256(actionFee1)].toMemoryArray() + }); + + actionData = ActionData({ + erc20Ids: new uint256[](0), + erc20Amounts: new uint256[](0), + erc721Ids: [uint256(0)].toMemoryArray(), + feeInfo: feeInfo, + approvalFlags: type(uint256).max, + actionSelectorId: 0, + actionCalldata: abi.encode(params), + hookActionData: '', + extraData: '', + deadline: block.timestamp + 1 days, + nonce: 0 + }); + } + + function _minimalHookData(uint256 nftId) + internal + pure + returns (BaseTickBasedZapMigrateHook.ZapMigrateHookData memory hookData) + { + hookData.nftId = nftId; + hookData.maxDistanceFromLowerTickBeforeMigration = type(int24).max; + hookData.maxDistanceFromUpperTickBeforeMigration = type(int24).max; + hookData.minDistanceFromLowerTickAfterMigration = type(int24).min; + hookData.minDistanceFromUpperTickAfterMigration = type(int24).min; + hookData.maxTickRangeLength = type(int24).max; + hookData.maxValueReductionPerAction = FEE_PRECISION; + hookData.maxFee0 = FEE_PRECISION; + hookData.maxFee1 = FEE_PRECISION; + } + + function _poolKey() internal pure returns (PoolKey memory key) { + key = PoolKey({ + currency0: TOKEN0, + currency1: TOKEN1, + fee: POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: POOL_HOOKS + }); + } + + function _clamp(uint256 value, uint256 min, uint256 max) internal pure returns (uint256) { + if (value < min) return min; + if (value > max) return max; + return value; + } + + function _clampInt24(int24 value, int24 min, int24 max) internal pure returns (int24) { + if (value < min) return min; + if (value > max) return max; + return value; + } + + receive() external payable {} +}