diff --git a/.forge-snapshots/testDonationSnipingPrevention.snapshot b/.forge-snapshots/testDonationSnipingPrevention.snapshot new file mode 100644 index 0000000..32ef641 --- /dev/null +++ b/.forge-snapshots/testDonationSnipingPrevention.snapshot @@ -0,0 +1 @@ +AntiSnipingTest:testDonationSnipingPrevention() (gas: 1221314) \ No newline at end of file diff --git a/.forge-snapshots/testFeeRedistributionWhenNoLiquidity.snapshot b/.forge-snapshots/testFeeRedistributionWhenNoLiquidity.snapshot new file mode 100644 index 0000000..99a5657 --- /dev/null +++ b/.forge-snapshots/testFeeRedistributionWhenNoLiquidity.snapshot @@ -0,0 +1 @@ +AntiSnipingTest:testFeeRedistributionWhenNoLiquidity() (gas: 783889) \ No newline at end of file diff --git a/.forge-snapshots/testSwapAfterIncreaseLiquidity.snapshot b/.forge-snapshots/testSwapAfterIncreaseLiquidity.snapshot new file mode 100644 index 0000000..1610ae9 --- /dev/null +++ b/.forge-snapshots/testSwapAfterIncreaseLiquidity.snapshot @@ -0,0 +1 @@ +AntiSnipingTest:testSwapAfterIncreaseLiquidity() (gas: 1079845) \ No newline at end of file diff --git a/.forge-snapshots/testSwapFeeSnipingPrevention.snapshot b/.forge-snapshots/testSwapFeeSnipingPrevention.snapshot new file mode 100644 index 0000000..fb6b6c2 --- /dev/null +++ b/.forge-snapshots/testSwapFeeSnipingPrevention.snapshot @@ -0,0 +1 @@ +AntiSnipingTest:testSwapFeeSnipingPrevention() (gas: 1309635) \ No newline at end of file diff --git a/lib/forge-gas-snapshot b/lib/forge-gas-snapshot index 03b10b1..cf34ad1 160000 --- a/lib/forge-gas-snapshot +++ b/lib/forge-gas-snapshot @@ -1 +1 @@ -Subproject commit 03b10b10574e069081f6b84f5e1244e42041511d +Subproject commit cf34ad1ed0a1f323e77557b9bce420f3385f7400 diff --git a/lib/forge-std b/lib/forge-std index beb836e..2b59872 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit beb836e33f9a207f4927abb7cd09ad0afe4b3f9f +Subproject commit 2b59872eee0b8088ddcade39fe8c041e17bb79c0 diff --git a/lib/pancake-v4-universal-router b/lib/pancake-v4-universal-router index 89abc3f..ff63044 160000 --- a/lib/pancake-v4-universal-router +++ b/lib/pancake-v4-universal-router @@ -1 +1 @@ -Subproject commit 89abc3fd088b52354d4ac236edb3f68f31e9a3f4 +Subproject commit ff630444255e9fa12b1a90a55e2f0a16223ed9c3 diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol new file mode 100644 index 0000000..92dcdb3 --- /dev/null +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (C) 2024 PancakeSwap +pragma solidity ^0.8.19; + +import {CLBaseHook} from "../CLBaseHook.sol"; +import {IPoolManager} from "pancake-v4-core/src/interfaces/IPoolManager.sol"; +import {ICLPoolManager} from "pancake-v4-core/src/pool-cl/interfaces/ICLPoolManager.sol"; +import {Tick} from "pancake-v4-core/src/pool-cl/libraries/Tick.sol"; +import {Hooks} from "pancake-v4-core/src/libraries/Hooks.sol"; +import {CLPosition} from "pancake-v4-core/src/pool-cl/libraries/CLPosition.sol"; +import {PoolKey} from "pancake-v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "pancake-v4-core/src/types/PoolId.sol"; +import {BalanceDelta, BalanceDeltaLibrary, toBalanceDelta} from "pancake-v4-core/src/types/BalanceDelta.sol"; +import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "pancake-v4-core/src/types/BeforeSwapDelta.sol"; +import {FullMath} from "pancake-v4-core/src/pool-cl/libraries/FullMath.sol"; +import {FixedPoint128} from "pancake-v4-core/src/pool-cl/libraries/FixedPoint128.sol"; +import {SafeCast} from "pancake-v4-core/src/libraries/SafeCast.sol"; + +/* + * @dev Disclaimer: + * - This contract has not been audited. + * - Developers using this code are advised to thoroughly review and test it before deploying it to production. + */ + +/// @title AntiSnipingHook +/// @notice A PancakeSwap V4 hook that prevents MEV sniping attacks by enforcing time locks on positions and redistributing fees accrued in the initial block to legitimate liquidity providers. +/// Notice this hook only prevents getting fees from swaps or donations in the same block, but does not prevent any other type of MEV attacks such as sandwiching or frontrunning swaps. +/// @dev Positions are time-locked, and fees accrued in the first block after position creation are redistributed. +contract CLAntiSniping is CLBaseHook { + using PoolIdLibrary for PoolKey; + using SafeCast for *; + + /// @notice Maps a pool ID and position key to the block number when the position was created. + mapping(PoolId => mapping(bytes32 => uint256)) public positionCreationBlock; + + /// @notice The duration (in blocks) for which a position must remain locked before it can be removed. + uint128 public immutable positionLockDuration; + + uint128 public constant MAX_LOCK_DURATION = 7500000; + + /// @notice The maximum number of positions that can be created in the same block per pool to prevent excessive gas usage. + uint128 public immutable sameBlockPositionsLimit; + + uint128 public constant MIN_SAME_BLOCK_POSITIONS_LIMIT = 50; + + mapping(PoolId => uint256) lastProcessedBlockNumber; + + mapping(PoolId => bytes32[]) positionsCreatedInLastBlock; + + struct LiquidityParams { + int24 tickLower; + int24 tickUpper; + bytes32 salt; + address sender; + } + mapping(bytes32 => LiquidityParams) positionKeyToLiquidityParams; + + /// @notice Maps a pool ID and position key to the fees accrued in the first block. + mapping(PoolId => mapping(bytes32 => uint256)) public firstBlockFeesToken0; + mapping(PoolId => mapping(bytes32 => uint256)) public firstBlockFeesToken1; + + /// @notice Error thrown when a position is still locked and cannot be removed. + error PositionLocked(); + + /// @notice Error thrown when attempting to modify an existing position. + /// @dev Positions cannot be modified after creation to prevent edge cases. + error PositionAlreadyExistsAndLocked(); + + /// @notice Error thrown when attempting to partially withdraw from a position. + error PositionPartiallyWithdrawn(); + + /// @notice Error thrown when too many positions are opened in the same block. + /// @dev Limits the number of positions per block to prevent excessive gas consumption. + error TooManyPositionsInSameBlock(); + + constructor(ICLPoolManager poolManager, uint128 _positionLockDuration, uint128 _sameBlockPositionsLimit) + CLBaseHook(poolManager) + { + if (_positionLockDuration < MAX_LOCK_DURATION) { + positionLockDuration = _positionLockDuration; + } else { + positionLockDuration = MAX_LOCK_DURATION; + } + if (_sameBlockPositionsLimit > MIN_SAME_BLOCK_POSITIONS_LIMIT) { + sameBlockPositionsLimit = _sameBlockPositionsLimit; + } else { + sameBlockPositionsLimit = MIN_SAME_BLOCK_POSITIONS_LIMIT; + } + } + + function getHooksRegistrationBitmap() external pure override returns (uint16) { + return _hooksRegistrationBitmapFrom( + Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: true, + afterAddLiquidity: false, + beforeRemoveLiquidity: true, + afterRemoveLiquidity: true, + beforeSwap: true, + afterSwap: false, + beforeDonate: true, + afterDonate: false, + beforeSwapReturnsDelta: false, + afterSwapReturnsDelta: false, + afterAddLiquidiyReturnsDelta: false, + afterRemoveLiquidiyReturnsDelta: true + }) + ); + } + + /// @notice Collects fee information for positions created in the last processed block. + /// @dev This is called in all of the before hooks (except init) and can also be called manually. + /// @param poolId The identifier of the pool. + function collectLastBlockInfo(PoolId poolId) public { + if (block.number <= lastProcessedBlockNumber[poolId]) { + return; + } + + lastProcessedBlockNumber[poolId] = block.number; + for (uint256 i = 0; i < positionsCreatedInLastBlock[poolId].length; i++) { + bytes32 positionKey = positionsCreatedInLastBlock[poolId][i]; + LiquidityParams memory params = positionKeyToLiquidityParams[positionKey]; + CLPosition.Info memory info = poolManager.getPosition(poolId, params.sender, params.tickLower, params.tickUpper, params.salt); + (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) = _getFeeGrowthInside(poolId, params.sender, params.tickLower, params.tickUpper, params.salt); + uint256 feeGrowthDelta0X128 = feeGrowthInside0X128 - info.feeGrowthInside0LastX128; + uint256 feeGrowthDelta1X128 = feeGrowthInside1X128 - info.feeGrowthInside1LastX128; + firstBlockFeesToken0[poolId][positionKey] += + FullMath.mulDiv(feeGrowthDelta0X128, info.liquidity, FixedPoint128.Q128); + firstBlockFeesToken1[poolId][positionKey] += + FullMath.mulDiv(feeGrowthDelta1X128, info.liquidity, FixedPoint128.Q128); + } + + delete positionsCreatedInLastBlock[poolId]; + } + + /// @notice Handles logic after removing liquidity, redistributing first-block fees if applicable. + /// @dev Donates first-block accrued fees to the pool if liquidity remains; otherwise, returns them to the sender. + function afterRemoveLiquidity( + address sender, + PoolKey calldata key, + ICLPoolManager.ModifyLiquidityParams calldata params, + BalanceDelta, + BalanceDelta, + bytes calldata + ) external override poolManagerOnly returns (bytes4, BalanceDelta) { + PoolId poolId = key.toId(); + bytes32 positionKey = CLPosition.calculatePositionKey(sender, params.tickLower, params.tickUpper, params.salt); + + BalanceDelta hookDelta; + if (poolManager.getLiquidity(poolId) != 0) { + hookDelta = toBalanceDelta( + firstBlockFeesToken0[poolId][positionKey].toInt128(), + firstBlockFeesToken1[poolId][positionKey].toInt128() + ); + poolManager.donate( + key, firstBlockFeesToken0[poolId][positionKey], firstBlockFeesToken1[poolId][positionKey], new bytes(0) + ); + } else { + // If the pool is empty, the fees are not donated and are returned to the sender + hookDelta = BalanceDeltaLibrary.ZERO_DELTA; + } + + // Cleanup stored data for the position + delete positionCreationBlock[poolId][positionKey]; + delete firstBlockFeesToken0[poolId][positionKey]; + delete firstBlockFeesToken1[poolId][positionKey]; + + return (this.afterRemoveLiquidity.selector, hookDelta); + } + + /// @notice Handles logic before adding liquidity, enforcing position creation constraints. + /// @dev Records position creation block and ensures the position doesn't already exist or exceed the same block limit. + function beforeAddLiquidity( + address sender, + PoolKey calldata key, + ICLPoolManager.ModifyLiquidityParams calldata params, + bytes calldata + ) external override poolManagerOnly returns (bytes4) { + PoolId poolId = key.toId(); + collectLastBlockInfo(poolId); + bytes32 positionKey = CLPosition.calculatePositionKey(sender, params.tickLower, params.tickUpper, params.salt); + LiquidityParams storage liqParams = positionKeyToLiquidityParams[positionKey]; + liqParams.sender = sender; + liqParams.tickLower = params.tickLower; + liqParams.tickUpper = params.tickUpper; + liqParams.salt = params.salt; + + if (positionCreationBlock[poolId][positionKey] != 0 && + block.number - positionCreationBlock[poolId][positionKey] <= positionLockDuration) { + revert PositionAlreadyExistsAndLocked(); + } + if (positionsCreatedInLastBlock[poolId].length >= sameBlockPositionsLimit) revert TooManyPositionsInSameBlock(); + if (positionCreationBlock[poolId][positionKey] == 0) { + positionsCreatedInLastBlock[poolId].push(positionKey); + } + positionCreationBlock[poolId][positionKey] = block.number; + + return (this.beforeAddLiquidity.selector); + } + + /// @notice Handles logic before removing liquidity, enforcing position lock duration and full withdrawal. + /// @dev Checks that the position lock duration has passed and disallows partial withdrawals. + function beforeRemoveLiquidity( + address sender, + PoolKey calldata key, + ICLPoolManager.ModifyLiquidityParams calldata params, + bytes calldata + ) external override poolManagerOnly returns (bytes4) { + PoolId poolId = key.toId(); + collectLastBlockInfo(poolId); + bytes32 positionKey = CLPosition.calculatePositionKey(sender, params.tickLower, params.tickUpper, params.salt); + if (block.number - positionCreationBlock[poolId][positionKey] <= positionLockDuration) revert PositionLocked(); + CLPosition.Info memory info = poolManager.getPosition(poolId, sender, params.tickLower, params.tickUpper, params.salt); + if (int128(info.liquidity) + params.liquidityDelta != 0) revert PositionPartiallyWithdrawn(); + return (this.beforeRemoveLiquidity.selector); + } + + /// @notice Handles logic before a swap occurs. + /// @dev Collects fee information for positions created in the last processed block. + function beforeSwap(address, PoolKey calldata key, ICLPoolManager.SwapParams calldata, bytes calldata) + external + override + poolManagerOnly + returns (bytes4, BeforeSwapDelta, uint24) + { + PoolId poolId = key.toId(); + collectLastBlockInfo(poolId); + return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + } + + /// @notice Handles logic before a donation occurs. + /// @dev Collects fee information for positions created in the last processed block. + function beforeDonate(address, PoolKey calldata key, uint256, uint256, bytes calldata) + external + override + poolManagerOnly + returns (bytes4) + { + PoolId poolId = key.toId(); + collectLastBlockInfo(poolId); + return (this.beforeDonate.selector); + } + + function _getFeeGrowthInside( + PoolId poolId, + address owner, + int24 tickLower, + int24 tickUpper, + bytes32 salt + ) internal view returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) { + (, int24 tickCurrent,,) = poolManager.getSlot0(poolId); + Tick.Info memory lower = poolManager.getPoolTickInfo(poolId, tickLower); + Tick.Info memory upper = poolManager.getPoolTickInfo(poolId, tickUpper); + + (uint256 feeGrowthGlobal0X128, uint256 feeGrowthGlobal1X128) = poolManager.getFeeGrowthGlobals(poolId); + + // calculate fee growth below + uint256 feeGrowthBelow0X128; + uint256 feeGrowthBelow1X128; + + unchecked { + if (tickCurrent >= tickLower) { + feeGrowthBelow0X128 = lower.feeGrowthOutside0X128; + feeGrowthBelow1X128 = lower.feeGrowthOutside1X128; + } else { + feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128; + feeGrowthBelow1X128 = feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128; + } + + // calculate fee growth above + 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; + } + } +} \ No newline at end of file diff --git a/test/pool-cl/CLAntiSniping.t.sol b/test/pool-cl/CLAntiSniping.t.sol new file mode 100644 index 0000000..261567c --- /dev/null +++ b/test/pool-cl/CLAntiSniping.t.sol @@ -0,0 +1,498 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; + +import {ICLPoolManager} from "pancake-v4-core/src/pool-cl/interfaces/ICLPoolManager.sol"; +import {IVault} from "pancake-v4-core/src/interfaces/IVault.sol"; +import {CLPoolManager} from "pancake-v4-core/src/pool-cl/CLPoolManager.sol"; +import {Vault} from "pancake-v4-core/src/Vault.sol"; +import {Currency} from "pancake-v4-core/src/types/Currency.sol"; +import {PoolKey} from "pancake-v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "pancake-v4-core/src/types/PoolId.sol"; +import {CLPoolParametersHelper} from "pancake-v4-core/src/pool-cl/libraries/CLPoolParametersHelper.sol"; +import {TickMath} from "pancake-v4-core/src/pool-cl/libraries/TickMath.sol"; +import {SortTokens} from "pancake-v4-core/test/helpers/SortTokens.sol"; +import {Deployers} from "pancake-v4-core/test/pool-cl/helpers/Deployers.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import {Hooks} from "pancake-v4-core/src/libraries/Hooks.sol"; +import {ICLRouterBase} from "pancake-v4-periphery/src/pool-cl/interfaces/ICLRouterBase.sol"; +import {DeployPermit2} from "permit2/test/utils/DeployPermit2.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; + +import {MockCLSwapRouter} from "./helpers/MockCLSwapRouter.sol"; +import {MockCLPositionManager} from "./helpers/MockCLPositionManager.sol"; +import {CLPosition} from "pancake-v4-core/src/pool-cl/libraries/CLPosition.sol"; +import {CLPoolManagerRouter} from "pancake-v4-core/test/pool-cl/helpers/CLPoolManagerRouter.sol"; +import {CLAntiSniping} from "../../src/pool-cl/anti-sniping/CLAntiSniping.sol"; +import {Planner, Plan} from "pancake-v4-periphery/src/libraries/Planner.sol"; +import {Actions} from "pancake-v4-periphery/src/libraries/Actions.sol"; + +contract AntiSnipingTest is Test, Deployers, DeployPermit2 { + using PoolIdLibrary for PoolKey; + using CLPoolParametersHelper for bytes32; + using Planner for Plan; + + uint24 constant FEE = 3000; + uint128 constant POSITION_LOCK_DURATION = 1000; + uint128 constant SAME_BLOCK_POSITIONS_LIMIT = 50; + + address constant ALICE = address(0x1111); // ALICE is an honest liquidity provider + address constant BOB = address(0x2222); // BOB is wanna-be sniper + address constant CANDY = address(0x3333); // CANDY is a normal user + bytes32 constant ALICE_SALT = 0x0000000000000000000000000000000000000000000000000000000000000001; + bytes32 constant BOB_SALT = 0x0000000000000000000000000000000000000000000000000000000000000002; + + IVault vault; + ICLPoolManager poolManager; + IAllowanceTransfer permit2; + MockCLPositionManager cpm; + MockCLSwapRouter swapRouter; + + CLAntiSniping antiSniping; + CLPoolManagerRouter router; + + MockERC20 token0; + MockERC20 token1; + Currency currency0; + Currency currency1; + PoolKey key; + PoolId id; + uint256 aliceTokenId; + uint256 bobTokenId; + + function setUp() public { + (vault, poolManager) = createFreshManager(); + antiSniping = new CLAntiSniping(poolManager, POSITION_LOCK_DURATION, SAME_BLOCK_POSITIONS_LIMIT); + + permit2 = IAllowanceTransfer(deployPermit2()); + cpm = new MockCLPositionManager(vault, poolManager, permit2); + swapRouter = new MockCLSwapRouter(vault, poolManager); + router = new CLPoolManagerRouter(vault, poolManager); + + MockERC20[] memory tokens = deployTokens(2, type(uint256).max); + token0 = tokens[0]; + token1 = tokens[1]; + (currency0, currency1) = SortTokens.sort(token0, token1); + + address[5] memory approvalAddress = [address(cpm), address(swapRouter), address(router), address(antiSniping), address(permit2)]; + + vm.startPrank(ALICE); + for (uint256 i; i < approvalAddress.length; i++) { + token0.approve(approvalAddress[i], type(uint256).max); + token1.approve(approvalAddress[i], type(uint256).max); + } + permit2.approve(address(token0), address(cpm), type(uint160).max, type(uint48).max); + permit2.approve(address(token1), address(cpm), type(uint160).max, type(uint48).max); + vm.stopPrank(); + + vm.startPrank(BOB); + for (uint256 i; i < approvalAddress.length; i++) { + token0.approve(approvalAddress[i], type(uint256).max); + token1.approve(approvalAddress[i], type(uint256).max); + } + permit2.approve(address(token0), address(cpm), type(uint160).max, type(uint48).max); + permit2.approve(address(token1), address(cpm), type(uint160).max, type(uint48).max); + vm.stopPrank(); + + vm.startPrank(CANDY); + for (uint256 i; i < approvalAddress.length; i++) { + token0.approve(approvalAddress[i], type(uint256).max); + token1.approve(approvalAddress[i], type(uint256).max); + } + permit2.approve(address(token0), address(cpm), type(uint160).max, type(uint48).max); + permit2.approve(address(token1), address(cpm), type(uint160).max, type(uint48).max); + vm.stopPrank(); + + key = PoolKey({ + currency0: currency0, + currency1: currency1, + hooks: antiSniping, + poolManager: poolManager, + fee: FEE, + parameters: bytes32(uint256(antiSniping.getHooksRegistrationBitmap())).setTickSpacing(60) + }); + id = key.toId(); + + poolManager.initialize(key, SQRT_RATIO_1_1); + + token0.transfer(ALICE, 10000 ether); + token1.transfer(ALICE, 10000 ether); + token0.transfer(BOB, 10000 ether); + token1.transfer(BOB, 10000 ether); + token0.transfer(CANDY, 10000 ether); + token1.transfer(CANDY, 10000 ether); + } + + // Helper function to mint liquidity positions + function _mintLiquidityPosition( + address user, + int24 tickLower, + int24 tickUpper, + uint256 liquidityDelta, + bytes memory hookData + ) internal returns (uint256 tokenId) { + vm.prank(user); + (tokenId, ) = cpm.mint( + key, + tickLower, + tickUpper, + // liquidity: + liquidityDelta, + // amount0Max: + 10000e18, + // amount1Max: + 10000e18, + // owner: + user, + // hookData: + hookData + ); + } + + // Helper function to increase liquidity positions (add) + function _increaseLiquidityPosition( + address user, + uint256 tokenId, + uint256 liquidityDelta, + bytes memory hookData + ) internal returns (uint256) { + vm.prank(user); + cpm.increaseLiquidity( + // tokenId: + tokenId, + // poolKey: + key, + // liquidity: + liquidityDelta, + // amount0Max: + 10000e18, + // amount1Max: + 10000e18, + // hookData: + hookData + ); + + return tokenId; + } + + // Helper function to decrease liquidity positions (remove) + function _decreaseLiquidityPosition( + address user, + uint256 tokenId, + uint256 liquidityDelta, + bytes memory hookData + ) internal { + vm.prank(user); + cpm.decreaseLiquidity( + // tokenId: + tokenId, + // poolKey: + key, + // liquidity: + liquidityDelta, + // amount0Min: + 0, + // amount1Min: + 0, + // hookData: + hookData + ); + } + + // Helper function to perform a swap + function _performSwapExactOutputSingle(address user, bool zeroForOne, uint128 amountOut) internal { + vm.prank(user); + swapRouter.exactOutputSingle( + ICLRouterBase.CLSwapExactOutputSingleParams({ + poolKey: key, + zeroForOne: zeroForOne, + amountOut: amountOut, + amountInMaximum: 1.01 ether, + hookData: ZERO_BYTES + }), + block.timestamp + ); + } + + // --- Test Scenarios --- + + function testGetParameters() public { + assertEq(antiSniping.positionLockDuration(), POSITION_LOCK_DURATION); + assertEq(antiSniping.sameBlockPositionsLimit(), SAME_BLOCK_POSITIONS_LIMIT); + } + + /// @notice Test that swap fee sniping is prevented + function testSwapFeeSnipingPrevention() public { + // Record initial balances + uint256 aliceToken0Before = currency0.balanceOf(ALICE); + uint256 aliceToken1Before = currency1.balanceOf(ALICE); + uint256 bobToken0Before = currency0.balanceOf(BOB); + uint256 bobToken1Before = currency1.balanceOf(BOB); + + aliceTokenId = _mintLiquidityPosition(ALICE, -60, 60, 10000 ether, ZERO_BYTES); + + // Advance to next block + vm.roll(2); + + bobTokenId = _mintLiquidityPosition(BOB, -60, 60, 10000 ether, ZERO_BYTES); + + // Swap occurs in the same block + uint128 swapAmount = 1 ether; + _performSwapExactOutputSingle(CANDY, true, swapAmount); + + // Expected fees from swap + uint256 token0ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; // Swap amount * fee percentage + + // Advance to next block and perform another swap + vm.roll(3); + _performSwapExactOutputSingle(CANDY, false, swapAmount); + uint256 token1ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; + + // Collect fee info + PoolId poolId = key.toId(); + antiSniping.collectLastBlockInfo(poolId); + + // Calculate position keys + bytes32 alicePositionKey = CLPosition.calculatePositionKey(address(cpm), -60, 60, ALICE_SALT); + bytes32 bobPositionKey = + CLPosition.calculatePositionKey(address(cpm), -60, 60, BOB_SALT); + + // Verify that Alice did not accrue fees in the creation block + assertEq(antiSniping.firstBlockFeesToken0(poolId, alicePositionKey), 0); + assertEq(antiSniping.firstBlockFeesToken1(poolId, alicePositionKey), 0); + + // Verify that Bob accrued fees from the first swap + assertApproxEqAbsDecimal(antiSniping.firstBlockFeesToken0(poolId, bobPositionKey), token0ExpectedFees / 2, 1e15, 18); + assertEq(antiSniping.firstBlockFeesToken1(poolId, bobPositionKey), 0); + + // Advance to after position lock duration + vm.roll(POSITION_LOCK_DURATION + 3); + + // Bob removes liquidity + _decreaseLiquidityPosition(BOB, bobTokenId, 10000 ether, ZERO_BYTES); + + // Verify that Bob received fees from the second swap only + uint256 bobToken0After = currency0.balanceOf(BOB); + uint256 bobToken1After = currency1.balanceOf(BOB); + assertApproxEqAbsDecimal(bobToken0After, bobToken0Before, 1e15, 18); + assertApproxEqAbsDecimal(bobToken1After, bobToken1Before + token1ExpectedFees / 2, 1e15, 18); + + // Alice removes liquidity + _decreaseLiquidityPosition(ALICE, aliceTokenId, 10000 ether, ZERO_BYTES); + + // Verify that Alice received full fees from the first swap and half from the second + uint256 aliceToken0After = currency0.balanceOf(ALICE); + uint256 aliceToken1After = currency1.balanceOf(ALICE); + assertApproxEqAbsDecimal(aliceToken0After, aliceToken0Before + token0ExpectedFees, 1e15, 18); + assertApproxEqAbsDecimal(aliceToken1After, aliceToken1Before + token1ExpectedFees / 2, 1e15, 18); + } + + /// @notice Test that donation sniping is prevented + function testDonationSnipingPrevention() public { + // Record initial balances + uint256 aliceToken0Before = currency0.balanceOf(ALICE); + uint256 aliceToken1Before = currency1.balanceOf(ALICE); + uint256 bobToken0Before = currency0.balanceOf(BOB); + uint256 bobToken1Before = currency1.balanceOf(BOB); + + // Alice adds liquidity + aliceTokenId = _mintLiquidityPosition(ALICE, -60, 60, 10000 ether, ZERO_BYTES); + + // Advance to next block + vm.roll(2); + + bobTokenId = _mintLiquidityPosition(BOB, -60, 60, 10000 ether, ZERO_BYTES); + + vm.prank(CANDY); + + // Donation occurs + uint256 token0Donation = 1 ether; + uint256 token1Donation = 2 ether; + router.donate(key, token0Donation, token1Donation, ZERO_BYTES); + + // Advance to next block and collect fee info + vm.roll(3); + PoolId poolId = key.toId(); + antiSniping.collectLastBlockInfo(poolId); + + // Calculate position keys + bytes32 alicePositionKey = CLPosition.calculatePositionKey(address(cpm), -60, 60, ALICE_SALT); + bytes32 bobPositionKey = + CLPosition.calculatePositionKey(address(cpm), -60, 60, BOB_SALT); + + // Verify that Alice did not accrue fees in the creation block + assertEq(antiSniping.firstBlockFeesToken0(poolId, alicePositionKey), 0); + assertEq(antiSniping.firstBlockFeesToken1(poolId, alicePositionKey), 0); + + // Verify that Bob accrued fees in the creation block + uint256 allowedError = 0.00001e18; // 0.001% + assertApproxEqRel(antiSniping.firstBlockFeesToken0(poolId, bobPositionKey), token0Donation / 2, allowedError); + assertApproxEqRel(antiSniping.firstBlockFeesToken1(poolId, bobPositionKey), token1Donation / 2, allowedError); + + // Advance to after position lock duration + vm.roll(POSITION_LOCK_DURATION + 3); + + // Bob removes liquidity + _decreaseLiquidityPosition(BOB, bobTokenId, 10000 ether, ZERO_BYTES); + + // Verify that Bob did not receive any fees + uint256 bobToken0After = currency0.balanceOf(BOB); + uint256 bobToken1After = currency1.balanceOf(BOB); + assertApproxEqRel(bobToken0After, bobToken0Before, allowedError); + assertApproxEqRel(bobToken1After, bobToken1Before, allowedError); + + // Alice removes liquidity + _decreaseLiquidityPosition(ALICE, aliceTokenId, 10000 ether, ZERO_BYTES); + + // Verify that Alice received all the donation fees + uint256 aliceToken0After = currency0.balanceOf(ALICE); + uint256 aliceToken1After = currency1.balanceOf(ALICE); + assertApproxEqRel(aliceToken0After, aliceToken0Before + token0Donation, allowedError); + assertApproxEqRel(aliceToken1After, aliceToken1Before + token1Donation, allowedError); + } + + /// @notice Test that fees are returned to the sender when no liquidity is left to donate to + function testFeeRedistributionWhenNoLiquidity() public { + // Record initial balance + uint256 aliceToken0Before = currency0.balanceOf(ALICE); + + // Alice adds liquidity + aliceTokenId = _mintLiquidityPosition(ALICE, -60, 60, 10000 ether, ZERO_BYTES); + + // Swap occurs in the same block + uint128 swapAmount = 1 ether; + _performSwapExactOutputSingle(CANDY, true, swapAmount); + uint256 token0ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; + + // Advance to next block and collect fee info + vm.roll(2); + PoolId poolId = key.toId(); + antiSniping.collectLastBlockInfo(poolId); + + // Calculate position keys + bytes32 alicePositionKey = CLPosition.calculatePositionKey(address(cpm), -60, 60, ALICE_SALT); + + // Verify that Alice accrued fees in the creation block + assertApproxEqAbsDecimal(antiSniping.firstBlockFeesToken0(poolId, alicePositionKey), token0ExpectedFees, 1e15, 18); + + // Advance to after position lock duration + vm.roll(POSITION_LOCK_DURATION + 2); + + // Alice removes liquidity + _decreaseLiquidityPosition(ALICE, aliceTokenId, 10000 ether, ZERO_BYTES); + + // Verify that fees are returned to Alice since there's no liquidity left to donate to + uint256 aliceToken0After = currency0.balanceOf(ALICE); + assertApproxEqAbsDecimal( + aliceToken0After, aliceToken0Before + uint256(swapAmount) + token0ExpectedFees, 1e15, 18 + ); + } + + /// @notice Test that swap after increase liquidity + function testSwapAfterIncreaseLiquidity() public { + // Record initial balances + uint256 aliceToken0Before = currency0.balanceOf(ALICE); + uint256 aliceToken1Before = currency1.balanceOf(ALICE); + + aliceTokenId = _mintLiquidityPosition(ALICE, -60, 60, 10000 ether, ZERO_BYTES); + + // Advance to next block + vm.roll(2); + + // Attempt to add liquidity and expect revert + vm.expectRevert(); + _increaseLiquidityPosition(ALICE, aliceTokenId, 10000 ether, ZERO_BYTES); + + // Advance to after position lock duration + vm.roll(POSITION_LOCK_DURATION + 3); + + // Alice add liquidity + _increaseLiquidityPosition(ALICE, aliceTokenId, 10000 ether, ZERO_BYTES); + + // Swap occurs in the same block + uint128 swapAmount = 1 ether; + _performSwapExactOutputSingle(CANDY, true, swapAmount); + + // Expected fees from swap + uint256 token0ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; // Swap amount * fee percentage + + // Advance to after position lock duration + vm.roll(POSITION_LOCK_DURATION + 4); + _performSwapExactOutputSingle(CANDY, false, swapAmount); + uint256 token1ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; + + // Collect fee info + PoolId poolId = key.toId(); + antiSniping.collectLastBlockInfo(poolId); + + // Calculate position keys + bytes32 alicePositionKey = CLPosition.calculatePositionKey(address(cpm), -60, 60, ALICE_SALT); + + // Verify that Alice did not accrue fees in the creation block + assertEq(antiSniping.firstBlockFeesToken0(poolId, alicePositionKey), 0); + assertEq(antiSniping.firstBlockFeesToken1(poolId, alicePositionKey), 0); + + // Attempt to remove liquidity and expect revert + vm.expectRevert(); + _decreaseLiquidityPosition(ALICE, aliceTokenId, 20000 ether, ZERO_BYTES); + + // Advance to after position lock duration + vm.roll(POSITION_LOCK_DURATION + POSITION_LOCK_DURATION + 5); + + // Attempt to remove liquidity partially and expect revert + vm.expectRevert(); + _decreaseLiquidityPosition(ALICE, aliceTokenId, 10000 ether, ZERO_BYTES); + + // Alice removes liquidity + _decreaseLiquidityPosition(ALICE, aliceTokenId, 20000 ether, ZERO_BYTES); + + // Verify that Alice received full fees from the first swap and half from the second + uint256 aliceToken0After = currency0.balanceOf(ALICE); + uint256 aliceToken1After = currency1.balanceOf(ALICE); + assertApproxEqAbsDecimal(aliceToken0After, aliceToken0Before + token0ExpectedFees, 1e15, 18); + assertApproxEqAbsDecimal(aliceToken1After, aliceToken1Before + token1ExpectedFees, 1e15, 18); + } + + // --- Safeguard Tests --- + + /// @notice Test that attempting to remove liquidity before lock duration reverts + function testEarlyLiquidityRemovalReverts() public { + // Alice adds liquidity + aliceTokenId = _mintLiquidityPosition(ALICE, -60, 60, 10 ether, ZERO_BYTES); + + // Advance a few blocks but less than lock duration + vm.roll(vm.getBlockNumber() + 5); + assertLt(5, antiSniping.positionLockDuration()); + + // Attempt to remove liquidity and expect revert + vm.expectRevert(); + _decreaseLiquidityPosition(ALICE, aliceTokenId, 10 ether, ZERO_BYTES); + } + + /// @notice Test that partial liquidity removal reverts + function testPartialLiquidityRemovalReverts() public { + // Alice adds liquidity + aliceTokenId = _mintLiquidityPosition(ALICE, -60, 60, 10 ether, ZERO_BYTES); + + // Advance past lock duration + vm.roll(POSITION_LOCK_DURATION); + + // Attempt to partially remove liquidity and expect revert + vm.expectRevert(); + _decreaseLiquidityPosition(ALICE, aliceTokenId, 5 ether, ZERO_BYTES); + } + + /// @notice Test that exceeding same block position limit reverts + function testExceedingSameBlockPositionsLimitReverts() public { + // Add positions up to the limit + for (uint256 i = 0; i < SAME_BLOCK_POSITIONS_LIMIT; ++i) { + _mintLiquidityPosition(ALICE, -60, 60, 10 ether, ZERO_BYTES); + } + + // Attempt to add one more position and expect revert + vm.expectRevert(); + _mintLiquidityPosition(ALICE, -60, 60, 10 ether, ZERO_BYTES); + } +} \ No newline at end of file diff --git a/test/pool-cl/CLAntiSnipingGasCosts.t.sol b/test/pool-cl/CLAntiSnipingGasCosts.t.sol new file mode 100644 index 0000000..e6f0b9c --- /dev/null +++ b/test/pool-cl/CLAntiSnipingGasCosts.t.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; + +import {ICLPoolManager} from "pancake-v4-core/src/pool-cl/interfaces/ICLPoolManager.sol"; +import {IVault} from "pancake-v4-core/src/interfaces/IVault.sol"; +import {CLPoolManager} from "pancake-v4-core/src/pool-cl/CLPoolManager.sol"; +import {Vault} from "pancake-v4-core/src/Vault.sol"; +import {Currency} from "pancake-v4-core/src/types/Currency.sol"; +import {PoolKey} from "pancake-v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "pancake-v4-core/src/types/PoolId.sol"; +import {CLPoolParametersHelper} from "pancake-v4-core/src/pool-cl/libraries/CLPoolParametersHelper.sol"; +import {TickMath} from "pancake-v4-core/src/pool-cl/libraries/TickMath.sol"; +import {SortTokens} from "pancake-v4-core/test/helpers/SortTokens.sol"; +import {Deployers} from "pancake-v4-core/test/pool-cl/helpers/Deployers.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import {Hooks} from "pancake-v4-core/src/libraries/Hooks.sol"; +import {ICLRouterBase} from "pancake-v4-periphery/src/pool-cl/interfaces/ICLRouterBase.sol"; +import {DeployPermit2} from "permit2/test/utils/DeployPermit2.sol"; +import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol"; + +import {MockCLSwapRouter} from "./helpers/MockCLSwapRouter.sol"; +import {MockCLPositionManager} from "./helpers/MockCLPositionManager.sol"; +import {CLPosition} from "pancake-v4-core/src/pool-cl/libraries/CLPosition.sol"; +import {CLPoolManagerRouter} from "pancake-v4-core/test/pool-cl/helpers/CLPoolManagerRouter.sol"; +import {CLAntiSniping} from "../../src/pool-cl/anti-sniping/CLAntiSniping.sol"; +import {Planner, Plan} from "pancake-v4-periphery/src/libraries/Planner.sol"; +import {Actions} from "pancake-v4-periphery/src/libraries/Actions.sol"; + +contract AntiSnipingTest is Test, Deployers, DeployPermit2 { + using PoolIdLibrary for PoolKey; + using CLPoolParametersHelper for bytes32; + using Planner for Plan; + + uint24 constant FEE = 3000; + uint128 constant POSITION_LOCK_DURATION = 1000; + uint128 constant SAME_BLOCK_POSITIONS_LIMIT = 50; + + address constant ALICE = address(0x1111); // ALICE is an honest liquidity provider + bytes32 constant ALICE_SALT = 0x0000000000000000000000000000000000000000000000000000000000000001; + + IVault vault; + ICLPoolManager poolManager; + IAllowanceTransfer permit2; + MockCLPositionManager cpm; + MockCLSwapRouter swapRouter; + + CLAntiSniping antiSniping; + CLPoolManagerRouter router; + + MockERC20 token0; + MockERC20 token1; + Currency currency0; + Currency currency1; + PoolKey key; + PoolId id; + uint256 aliceTokenId; + uint256 bobTokenId; + + function setUp() public { + (vault, poolManager) = createFreshManager(); + antiSniping = new CLAntiSniping(poolManager, POSITION_LOCK_DURATION, SAME_BLOCK_POSITIONS_LIMIT); + + permit2 = IAllowanceTransfer(deployPermit2()); + cpm = new MockCLPositionManager(vault, poolManager, permit2); + swapRouter = new MockCLSwapRouter(vault, poolManager); + router = new CLPoolManagerRouter(vault, poolManager); + + MockERC20[] memory tokens = deployTokens(2, type(uint256).max); + token0 = tokens[0]; + token1 = tokens[1]; + (currency0, currency1) = SortTokens.sort(token0, token1); + + address[5] memory approvalAddress = [address(cpm), address(swapRouter), address(router), address(antiSniping), address(permit2)]; + + vm.startPrank(ALICE); + for (uint256 i; i < approvalAddress.length; i++) { + token0.approve(approvalAddress[i], type(uint256).max); + token1.approve(approvalAddress[i], type(uint256).max); + } + permit2.approve(address(token0), address(cpm), type(uint160).max, type(uint48).max); + permit2.approve(address(token1), address(cpm), type(uint160).max, type(uint48).max); + vm.stopPrank(); + + key = PoolKey({ + currency0: currency0, + currency1: currency1, + hooks: antiSniping, + poolManager: poolManager, + fee: FEE, + parameters: bytes32(uint256(antiSniping.getHooksRegistrationBitmap())).setTickSpacing(60) + }); + id = key.toId(); + + poolManager.initialize(key, SQRT_RATIO_1_1); + + token0.transfer(ALICE, 10000 ether); + token1.transfer(ALICE, 10000 ether); + + // Add positions up to the limit + for (uint256 i = 0; i < SAME_BLOCK_POSITIONS_LIMIT; ++i) { + _mintLiquidityPosition(ALICE, -60, 60, 10 ether, ZERO_BYTES); + vm.roll(i+2); + } + } + + // Helper function to mint liquidity positions (add) + function _mintLiquidityPosition( + address user, + int24 tickLower, + int24 tickUpper, + uint256 liquidityDelta, + bytes memory hookData + ) internal returns (uint256 tokenId) { + vm.prank(user); + (tokenId, ) = cpm.mint( + key, + tickLower, + tickUpper, + // liquidity: + liquidityDelta, + // amount0Max: + 10000e18, + // amount1Max: + 10000e18, + // owner: + user, + // hookData: + hookData + ); + } + + // Helper function to decrease liquidity positions (remove) + function _decreaseLiquidityPosition( + address user, + uint256 tokenId, + uint256 liquidityDelta, + bytes memory hookData + ) internal { + vm.prank(user); + cpm.decreaseLiquidity( + // tokenId: + tokenId, + // poolKey: + key, + // liquidity: + liquidityDelta, + // amount0Min: + 0, + // amount1Min: + 0, + // hookData: + hookData + ); + } + + // Helper function to perform a swap + function _performSwapExactOutputSingle(address user, bool zeroForOne, uint128 amountOut) internal { + vm.prank(user); + swapRouter.exactOutputSingle( + ICLRouterBase.CLSwapExactOutputSingleParams({ + poolKey: key, + zeroForOne: zeroForOne, + amountOut: amountOut, + amountInMaximum: 1.01 ether, + hookData: ZERO_BYTES + }), + block.timestamp + ); + } + + /// @notice Test that exceeding same block position limit reverts + function testGasCosts() public { + _mintLiquidityPosition(ALICE, -60, 60, 10 ether, ZERO_BYTES); + } + +} \ No newline at end of file diff --git a/test/pool-cl/helpers/MockCLPositionManager.sol b/test/pool-cl/helpers/MockCLPositionManager.sol index 6f07a43..46fbe9e 100644 --- a/test/pool-cl/helpers/MockCLPositionManager.sol +++ b/test/pool-cl/helpers/MockCLPositionManager.sol @@ -46,6 +46,23 @@ contract MockCLPositionManager is CLPositionManager, CommonBase { liquidityMinted = _getLiquidity(tokenId, poolKey, tickLower, tickUpper); } + function increaseLiquidity( + uint256 tokenId, + PoolKey calldata poolKey, + uint256 liquidity, + uint128 amount0Max, + uint128 amount1Max, + bytes calldata hookData + ) external payable { + Plan memory planner = Planner.init().add( + Actions.CL_INCREASE_LIQUIDITY, abi.encode(tokenId, liquidity, amount0Max, amount1Max, hookData) + ); + bytes memory data = planner.finalizeModifyLiquidityWithClose(poolKey); + + vm.prank(msg.sender); + this.modifyLiquidities(data, block.timestamp); + } + function decreaseLiquidity( uint256 tokenId, PoolKey calldata poolKey,