From 9596ab2a19cf18bd9c9dc9f4173b1e8da30d6a99 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Thu, 21 Nov 2024 10:28:29 +0800 Subject: [PATCH 01/14] feat: Add anti-sniping hook contract --- src/pool-cl/anti-sniping/CLAntiSniping.sol | 249 +++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 src/pool-cl/anti-sniping/CLAntiSniping.sol diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol new file mode 100644 index 0000000..57548ec --- /dev/null +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -0,0 +1,249 @@ +// 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"; + +/// @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. +/// @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 positionLockDuration; + + /// @notice The maximum number of positions that can be created in the same block per pool to prevent excessive gas usage. + uint128 public sameBlockPositionsLimit; + + 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 PositionAlreadyExists(); + + /// @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) + { + positionLockDuration = _positionLockDuration; + sameBlockPositionsLimit = _sameBlockPositionsLimit; + } + + function getHooksRegistrationBitmap() external pure override returns (uint16) { + return _hooksRegistrationBitmapFrom( + Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: true, + afterAddLiquidity: true, + beforeRemoveLiquidity: true, + afterRemoveLiquidity: false, + 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); + firstBlockFeesToken0[poolId][positionKey] = + FullMath.mulDiv(feeGrowthInside0X128 - info.feeGrowthInside0LastX128, info.liquidity, FixedPoint128.Q128); + firstBlockFeesToken1[poolId][positionKey] = + FullMath.mulDiv(feeGrowthInside1X128 - info.feeGrowthInside1LastX128, 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 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; + } + 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 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; + positionKeyToLiquidityParams[positionKey] = liqParams; + if (positionCreationBlock[poolId][positionKey] != 0) revert PositionAlreadyExists(); + if (positionsCreatedInLastBlock[poolId].length >= sameBlockPositionsLimit) revert TooManyPositionsInSameBlock(); + positionCreationBlock[poolId][positionKey] = block.number; + positionsCreatedInLastBlock[poolId].push(positionKey); + 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 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 + 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 + 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 From fbca282ae01340311c817d4d05de9dd57a0aa983 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Mon, 25 Nov 2024 15:53:53 +0800 Subject: [PATCH 02/14] err: Bob can't decrease liquidity --- test/pool-cl/CLAntiSnipingErr.t.sol | 252 ++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 test/pool-cl/CLAntiSnipingErr.t.sol diff --git a/test/pool-cl/CLAntiSnipingErr.t.sol b/test/pool-cl/CLAntiSnipingErr.t.sol new file mode 100644 index 0000000..86190a2 --- /dev/null +++ b/test/pool-cl/CLAntiSnipingErr.t.sol @@ -0,0 +1,252 @@ +// 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 {CLAntiSniping} from "../../src/pool-cl/anti-sniping/CLAntiSniping.sol"; +import {console} from "forge-std/console.sol"; +contract AntiSnipingTest is Test, Deployers, DeployPermit2 { + using PoolIdLibrary for PoolKey; + using CLPoolParametersHelper for bytes32; + + uint24 constant FEE = 3000; + uint128 constant POSITION_LOCK_DURATION = 1000; + uint128 constant SAME_BLOCK_POSITIONS_LIMIT = 5; + + address constant ALICE = address(0x1); // Alice is an honest liquidity provider + address constant BOB = address(0x2); // Bob is wanna-be sniper + address constant Candy = address(0x3); // 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; + + 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); + + MockERC20[] memory tokens = deployTokens(2, type(uint256).max); + token0 = tokens[0]; + token1 = tokens[1]; + (currency0, currency1) = SortTokens.sort(token0, token1); + + address[4] memory approvalAddress = [address(cpm), address(swapRouter), 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: 3000, + 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 (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 _performSwapExactInputSingle(address user, bool zeroForOne, uint128 amountIn) internal { + vm.prank(user); + swapRouter.exactInputSingle( + ICLRouterBase.CLSwapExactInputSingleParams({ + poolKey: key, + zeroForOne: zeroForOne, + amountIn: amountIn, + amountOutMinimum: 0, + 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); + + vm.roll(POSITION_LOCK_DURATION + 2); + + // Alice removes liquidity + _decreaseLiquidityPosition(BOB, bobTokenId, 10000 ether, ZERO_BYTES); + +// // Swap occurs in the same block +// uint128 swapAmount = 1 ether; +// _performSwapExactInputSingle(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); +// _performSwapExactInputSingle(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 + 2); +// +// console.log(bobTokenId); +// // Bob removes liquidity +// _decreaseLiquidityPosition(BOB, bobTokenId, 10000 ether, "0xBOB"); + } +} \ No newline at end of file From 7468dd45c761b49700eadd80c90195e6da232188 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Mon, 25 Nov 2024 22:26:14 +0800 Subject: [PATCH 03/14] fix: Fixed Bob can't remove liquidity --- ...tiSnipingErr.t.sol => CLAntiSniping.t.sol} | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) rename test/pool-cl/{CLAntiSnipingErr.t.sol => CLAntiSniping.t.sol} (80%) diff --git a/test/pool-cl/CLAntiSnipingErr.t.sol b/test/pool-cl/CLAntiSniping.t.sol similarity index 80% rename from test/pool-cl/CLAntiSnipingErr.t.sol rename to test/pool-cl/CLAntiSniping.t.sol index 86190a2..2fddfe8 100644 --- a/test/pool-cl/CLAntiSnipingErr.t.sol +++ b/test/pool-cl/CLAntiSniping.t.sol @@ -34,9 +34,9 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { uint128 constant POSITION_LOCK_DURATION = 1000; uint128 constant SAME_BLOCK_POSITIONS_LIMIT = 5; - address constant ALICE = address(0x1); // Alice is an honest liquidity provider - address constant BOB = address(0x2); // Bob is wanna-be sniper - address constant Candy = address(0x3); // Candy is a normal user + 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; @@ -208,45 +208,39 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { bobTokenId = _mintLiquidityPosition(BOB, -60, 60, 10000 ether, ZERO_BYTES); + // Swap occurs in the same block + uint128 swapAmount = 1 ether; + _performSwapExactInputSingle(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); + _performSwapExactInputSingle(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 + 2); - // Alice removes liquidity + // Bob removes liquidity _decreaseLiquidityPosition(BOB, bobTokenId, 10000 ether, ZERO_BYTES); - -// // Swap occurs in the same block -// uint128 swapAmount = 1 ether; -// _performSwapExactInputSingle(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); -// _performSwapExactInputSingle(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 + 2); -// -// console.log(bobTokenId); -// // Bob removes liquidity -// _decreaseLiquidityPosition(BOB, bobTokenId, 10000 ether, "0xBOB"); } } \ No newline at end of file From 02a9a803f73e7c07fe7b9d41ad1ab9b9b2269bdd Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Wed, 27 Nov 2024 12:23:47 +0800 Subject: [PATCH 04/14] err: FeeRedistributionWhenNoLiquidity assert error --- src/pool-cl/anti-sniping/CLAntiSniping.sol | 10 +- test/pool-cl/CLAntiSniping.t.sol | 187 +++++++++++++++++++-- 2 files changed, 183 insertions(+), 14 deletions(-) diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol index 57548ec..99bd2a4 100644 --- a/src/pool-cl/anti-sniping/CLAntiSniping.sol +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -75,9 +75,9 @@ contract CLAntiSniping is CLBaseHook { beforeInitialize: false, afterInitialize: false, beforeAddLiquidity: true, - afterAddLiquidity: true, + afterAddLiquidity: false, beforeRemoveLiquidity: true, - afterRemoveLiquidity: false, + afterRemoveLiquidity: true, beforeSwap: true, afterSwap: false, beforeDonate: true, @@ -103,10 +103,12 @@ contract CLAntiSniping is CLBaseHook { 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(feeGrowthInside0X128 - info.feeGrowthInside0LastX128, info.liquidity, FixedPoint128.Q128); + FullMath.mulDiv(feeGrowthDelta0X128, info.liquidity, FixedPoint128.Q128); firstBlockFeesToken1[poolId][positionKey] = - FullMath.mulDiv(feeGrowthInside1X128 - info.feeGrowthInside1LastX128, info.liquidity, FixedPoint128.Q128); + FullMath.mulDiv(feeGrowthDelta1X128, info.liquidity, FixedPoint128.Q128); } delete positionsCreatedInLastBlock[poolId]; } diff --git a/test/pool-cl/CLAntiSniping.t.sol b/test/pool-cl/CLAntiSniping.t.sol index 2fddfe8..ce26fce 100644 --- a/test/pool-cl/CLAntiSniping.t.sol +++ b/test/pool-cl/CLAntiSniping.t.sol @@ -24,19 +24,24 @@ 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"; + import {console} from "forge-std/console.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 = 5; - 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 + 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; @@ -47,6 +52,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { MockCLSwapRouter swapRouter; CLAntiSniping antiSniping; + CLPoolManagerRouter router; MockERC20 token0; MockERC20 token1; @@ -64,13 +70,14 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { 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[4] memory approvalAddress = [address(cpm), address(swapRouter), address(antiSniping), address(permit2)]; + address[5] memory approvalAddress = [address(cpm), address(swapRouter), address(router), address(antiSniping), address(permit2)]; vm.startPrank(ALICE); for (uint256 i; i < approvalAddress.length; i++) { @@ -90,7 +97,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { permit2.approve(address(token1), address(cpm), type(uint160).max, type(uint48).max); vm.stopPrank(); - vm.startPrank(Candy); + vm.startPrank(CANDY); for (uint256 i; i < approvalAddress.length; i++) { token0.approve(approvalAddress[i], type(uint256).max); token1.approve(approvalAddress[i], type(uint256).max); @@ -104,7 +111,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { currency1: currency1, hooks: antiSniping, poolManager: poolManager, - fee: 3000, + fee: FEE, parameters: bytes32(uint256(antiSniping.getHooksRegistrationBitmap())).setTickSpacing(60) }); id = key.toId(); @@ -115,8 +122,8 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { 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); + token0.transfer(CANDY, 10000 ether); + token1.transfer(CANDY, 10000 ether); } // Helper function to mint liquidity positions (add) @@ -210,14 +217,14 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { // Swap occurs in the same block uint128 swapAmount = 1 ether; - _performSwapExactInputSingle(Candy, true, swapAmount); + _performSwapExactInputSingle(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); - _performSwapExactInputSingle(Candy, false, swapAmount); + _performSwapExactInputSingle(CANDY, false, swapAmount); uint256 token1ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; // Collect fee info @@ -242,5 +249,165 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { // 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 + 2); + + // 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); + + uint256 aliceBalance = currency0.balanceOf(ALICE); + console.log(aliceBalance); + + // Swap occurs in the same block + uint128 swapAmount = 1 ether; + _performSwapExactInputSingle(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 + 1); + + // 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 + ); + } + + // --- 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, 10 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 From d77eff94f23cc6289efb60f3b43573f767b78db2 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Wed, 27 Nov 2024 21:39:44 +0800 Subject: [PATCH 05/14] fix: swap should be ExactOutputSingle --- test/pool-cl/CLAntiSniping.t.sol | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/test/pool-cl/CLAntiSniping.t.sol b/test/pool-cl/CLAntiSniping.t.sol index ce26fce..458db63 100644 --- a/test/pool-cl/CLAntiSniping.t.sol +++ b/test/pool-cl/CLAntiSniping.t.sol @@ -179,14 +179,14 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { } // Helper function to perform a swap - function _performSwapExactInputSingle(address user, bool zeroForOne, uint128 amountIn) internal { + function _performSwapExactOutputSingle(address user, bool zeroForOne, uint128 amountOut) internal { vm.prank(user); - swapRouter.exactInputSingle( - ICLRouterBase.CLSwapExactInputSingleParams({ + swapRouter.exactOutputSingle( + ICLRouterBase.CLSwapExactOutputSingleParams({ poolKey: key, zeroForOne: zeroForOne, - amountIn: amountIn, - amountOutMinimum: 0, + amountOut: amountOut, + amountInMaximum: 1.01 ether, hookData: ZERO_BYTES }), block.timestamp @@ -217,14 +217,14 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { // Swap occurs in the same block uint128 swapAmount = 1 ether; - _performSwapExactInputSingle(CANDY, true, swapAmount); + _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); - _performSwapExactInputSingle(CANDY, false, swapAmount); + _performSwapExactOutputSingle(CANDY, false, swapAmount); uint256 token1ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; // Collect fee info @@ -338,12 +338,9 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { // Alice adds liquidity aliceTokenId = _mintLiquidityPosition(ALICE, -60, 60, 10000 ether, ZERO_BYTES); - uint256 aliceBalance = currency0.balanceOf(ALICE); - console.log(aliceBalance); - // Swap occurs in the same block uint128 swapAmount = 1 ether; - _performSwapExactInputSingle(CANDY, true, swapAmount); + _performSwapExactOutputSingle(CANDY, true, swapAmount); uint256 token0ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; // Advance to next block and collect fee info From 4de9c872009bdd62bc99ceb05b6f99f57f18fcb3 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Mon, 2 Dec 2024 09:13:35 +0800 Subject: [PATCH 06/14] fix: Add immutable for constructor params and delete positions in afterRemoveLiquidity --- src/pool-cl/anti-sniping/CLAntiSniping.sol | 7 +++++-- test/pool-cl/CLAntiSniping.t.sol | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol index 99bd2a4..f5246d3 100644 --- a/src/pool-cl/anti-sniping/CLAntiSniping.sol +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -27,10 +27,10 @@ contract CLAntiSniping is CLBaseHook { 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 positionLockDuration; + uint128 public immutable positionLockDuration; /// @notice The maximum number of positions that can be created in the same block per pool to prevent excessive gas usage. - uint128 public sameBlockPositionsLimit; + uint128 public immutable sameBlockPositionsLimit; mapping(PoolId => uint256) lastProcessedBlockNumber; @@ -139,6 +139,9 @@ contract CLAntiSniping is CLBaseHook { // If the pool is empty, the fees are not donated and are returned to the sender hookDelta = BalanceDeltaLibrary.ZERO_DELTA; } + + positionCreationBlock[poolId][positionKey] = 0; + return (this.afterRemoveLiquidity.selector, hookDelta); } diff --git a/test/pool-cl/CLAntiSniping.t.sol b/test/pool-cl/CLAntiSniping.t.sol index 458db63..4bfc336 100644 --- a/test/pool-cl/CLAntiSniping.t.sol +++ b/test/pool-cl/CLAntiSniping.t.sol @@ -393,7 +393,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { // Attempt to partially remove liquidity and expect revert vm.expectRevert(); - _decreaseLiquidityPosition(ALICE, aliceTokenId, 10 ether, ZERO_BYTES); + _decreaseLiquidityPosition(ALICE, aliceTokenId, 5 ether, ZERO_BYTES); } /// @notice Test that exceeding same block position limit reverts From f2a92f904c7324995a11cb63a77807d8cf32e555 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Mon, 2 Dec 2024 15:51:41 +0800 Subject: [PATCH 07/14] feat: Add test case for gas snapshot --- src/pool-cl/anti-sniping/CLAntiSniping.sol | 2 + test/pool-cl/CLAntiSniping.t.sol | 4 +- test/pool-cl/CLAntiSnipingGasCosts.t.sol | 180 +++++++++++++++++++++ 3 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 test/pool-cl/CLAntiSnipingGasCosts.t.sol diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol index f5246d3..48e1cab 100644 --- a/src/pool-cl/anti-sniping/CLAntiSniping.sol +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -97,6 +97,7 @@ contract CLAntiSniping is CLBaseHook { if (block.number <= lastProcessedBlockNumber[poolId]) { return; } + lastProcessedBlockNumber[poolId] = block.number; for (uint256 i = 0; i < positionsCreatedInLastBlock[poolId].length; i++) { bytes32 positionKey = positionsCreatedInLastBlock[poolId][i]; @@ -110,6 +111,7 @@ contract CLAntiSniping is CLBaseHook { firstBlockFeesToken1[poolId][positionKey] = FullMath.mulDiv(feeGrowthDelta1X128, info.liquidity, FixedPoint128.Q128); } + delete positionsCreatedInLastBlock[poolId]; } diff --git a/test/pool-cl/CLAntiSniping.t.sol b/test/pool-cl/CLAntiSniping.t.sol index 4bfc336..7822f50 100644 --- a/test/pool-cl/CLAntiSniping.t.sol +++ b/test/pool-cl/CLAntiSniping.t.sol @@ -37,7 +37,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { uint24 constant FEE = 3000; uint128 constant POSITION_LOCK_DURATION = 1000; - uint128 constant SAME_BLOCK_POSITIONS_LIMIT = 5; + 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 @@ -174,8 +174,6 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { // hookData: hookData ); - - } // Helper function to perform a swap diff --git a/test/pool-cl/CLAntiSnipingGasCosts.t.sol b/test/pool-cl/CLAntiSnipingGasCosts.t.sol new file mode 100644 index 0000000..fc8b9c9 --- /dev/null +++ b/test/pool-cl/CLAntiSnipingGasCosts.t.sol @@ -0,0 +1,180 @@ +// 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"; + +import {console} from "forge-std/console.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 From b54b2b96d62956cc509260d3652a966d28f8e558 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Mon, 2 Dec 2024 16:04:16 +0800 Subject: [PATCH 08/14] fix: Update new commits --- lib/forge-gas-snapshot | 2 +- lib/forge-std | 2 +- lib/pancake-v4-universal-router | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 From f646684767e1b9262bbd2b5139055d5845fea978 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Tue, 3 Dec 2024 11:12:29 +0800 Subject: [PATCH 09/14] fix: Add back Cleanup stored data for the position --- src/pool-cl/anti-sniping/CLAntiSniping.sol | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol index 48e1cab..314f7d8 100644 --- a/src/pool-cl/anti-sniping/CLAntiSniping.sol +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -142,7 +142,10 @@ contract CLAntiSniping is CLBaseHook { hookDelta = BalanceDeltaLibrary.ZERO_DELTA; } - positionCreationBlock[poolId][positionKey] = 0; + // Cleanup stored data for the position + delete positionCreationBlock[poolId][positionKey]; + delete firstBlockFeesToken0[poolId][positionKey]; + delete firstBlockFeesToken1[poolId][positionKey]; return (this.afterRemoveLiquidity.selector, hookDelta); } From aa73e7d27ecca0f55fd02394b71f64c95756b394 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Tue, 17 Dec 2024 11:23:48 +0800 Subject: [PATCH 10/14] feat: User can increase liquidity after lock duration --- src/pool-cl/anti-sniping/CLAntiSniping.sol | 15 ++- test/pool-cl/CLAntiSniping.t.sol | 95 ++++++++++++++++++- .../pool-cl/helpers/MockCLPositionManager.sol | 17 ++++ 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol index 314f7d8..70742f6 100644 --- a/src/pool-cl/anti-sniping/CLAntiSniping.sol +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -15,7 +15,7 @@ import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "pancake-v4-core/src/types 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"; - +import "forge-std/console.sol"; /// @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. /// @dev Positions are time-locked, and fees accrued in the first block after position creation are redistributed. @@ -53,7 +53,7 @@ contract CLAntiSniping is CLBaseHook { /// @notice Error thrown when attempting to modify an existing position. /// @dev Positions cannot be modified after creation to prevent edge cases. - error PositionAlreadyExists(); + error PositionAlreadyExistsAndLocked(); /// @notice Error thrown when attempting to partially withdraw from a position. error PositionPartiallyWithdrawn(); @@ -167,10 +167,17 @@ contract CLAntiSniping is CLBaseHook { liqParams.tickUpper = params.tickUpper; liqParams.salt = params.salt; positionKeyToLiquidityParams[positionKey] = liqParams; - if (positionCreationBlock[poolId][positionKey] != 0) revert PositionAlreadyExists(); + + 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; - positionsCreatedInLastBlock[poolId].push(positionKey); + return (this.beforeAddLiquidity.selector); } diff --git a/test/pool-cl/CLAntiSniping.t.sol b/test/pool-cl/CLAntiSniping.t.sol index 7822f50..576c3b0 100644 --- a/test/pool-cl/CLAntiSniping.t.sol +++ b/test/pool-cl/CLAntiSniping.t.sol @@ -126,7 +126,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { token1.transfer(CANDY, 10000 ether); } - // Helper function to mint liquidity positions (add) + // Helper function to mint liquidity positions function _mintLiquidityPosition( address user, int24 tickLower, @@ -152,6 +152,32 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { ); } + // 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, @@ -161,7 +187,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { ) internal { vm.prank(user); cpm.decreaseLiquidity( - // tokenId: + // tokenId: tokenId, // poolKey: key, @@ -365,6 +391,71 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { ); } + /// @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 + 1); + + // 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 + 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); + + // 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 + 2); + + // 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 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, From de9289d21d2bdd1b2a3f5fc3eac4caf6a855f130 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Tue, 17 Dec 2024 11:39:22 +0800 Subject: [PATCH 11/14] chore: Delete unnecessary code and add gas snapshot --- .forge-snapshots/testDonationSnipingPrevention.snapshot | 1 + .forge-snapshots/testFeeRedistributionWhenNoLiquidity.snapshot | 1 + .forge-snapshots/testSwapAfterIncreaseLiquidity.snapshot | 1 + .forge-snapshots/testSwapFeeSnipingPrevention.snapshot | 1 + src/pool-cl/anti-sniping/CLAntiSniping.sol | 1 - test/pool-cl/CLAntiSniping.t.sol | 1 - 6 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 .forge-snapshots/testDonationSnipingPrevention.snapshot create mode 100644 .forge-snapshots/testFeeRedistributionWhenNoLiquidity.snapshot create mode 100644 .forge-snapshots/testSwapAfterIncreaseLiquidity.snapshot create mode 100644 .forge-snapshots/testSwapFeeSnipingPrevention.snapshot 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/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol index 70742f6..c2e7090 100644 --- a/src/pool-cl/anti-sniping/CLAntiSniping.sol +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -166,7 +166,6 @@ contract CLAntiSniping is CLBaseHook { liqParams.tickLower = params.tickLower; liqParams.tickUpper = params.tickUpper; liqParams.salt = params.salt; - positionKeyToLiquidityParams[positionKey] = liqParams; if (positionCreationBlock[poolId][positionKey] != 0 && block.number - positionCreationBlock[poolId][positionKey] < positionLockDuration) { diff --git a/test/pool-cl/CLAntiSniping.t.sol b/test/pool-cl/CLAntiSniping.t.sol index 576c3b0..8d901ee 100644 --- a/test/pool-cl/CLAntiSniping.t.sol +++ b/test/pool-cl/CLAntiSniping.t.sol @@ -29,7 +29,6 @@ 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"; -import {console} from "forge-std/console.sol"; contract AntiSnipingTest is Test, Deployers, DeployPermit2 { using PoolIdLibrary for PoolKey; using CLPoolParametersHelper for bytes32; From 52a58801f60bfeba32e17f5c7117b279491a1e1b Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Tue, 17 Dec 2024 22:03:32 +0800 Subject: [PATCH 12/14] chore: Remove console.sol --- src/pool-cl/anti-sniping/CLAntiSniping.sol | 2 +- test/pool-cl/CLAntiSnipingGasCosts.t.sol | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol index c2e7090..8225749 100644 --- a/src/pool-cl/anti-sniping/CLAntiSniping.sol +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -15,7 +15,7 @@ import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "pancake-v4-core/src/types 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"; -import "forge-std/console.sol"; + /// @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. /// @dev Positions are time-locked, and fees accrued in the first block after position creation are redistributed. diff --git a/test/pool-cl/CLAntiSnipingGasCosts.t.sol b/test/pool-cl/CLAntiSnipingGasCosts.t.sol index fc8b9c9..e6f0b9c 100644 --- a/test/pool-cl/CLAntiSnipingGasCosts.t.sol +++ b/test/pool-cl/CLAntiSnipingGasCosts.t.sol @@ -29,7 +29,6 @@ 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"; -import {console} from "forge-std/console.sol"; contract AntiSnipingTest is Test, Deployers, DeployPermit2 { using PoolIdLibrary for PoolKey; using CLPoolParametersHelper for bytes32; From 6f4bec3163c2846e109fe939435d9b5ff7a3c908 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Fri, 20 Dec 2024 20:57:13 +0800 Subject: [PATCH 13/14] chore: Add a disclaimer before review --- src/pool-cl/anti-sniping/CLAntiSniping.sol | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol index 8225749..a41c1d2 100644 --- a/src/pool-cl/anti-sniping/CLAntiSniping.sol +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -16,6 +16,12 @@ 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. /// @dev Positions are time-locked, and fees accrued in the first block after position creation are redistributed. From aa7f1abff14fde576805b57e956fe76951f94297 Mon Sep 17 00:00:00 2001 From: ChefCupcake Date: Mon, 30 Dec 2024 10:30:21 +0800 Subject: [PATCH 14/14] chore: Fix review bugs --- src/pool-cl/anti-sniping/CLAntiSniping.sol | 33 ++++++++++++++++------ test/pool-cl/CLAntiSniping.t.sol | 12 ++++---- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/pool-cl/anti-sniping/CLAntiSniping.sol b/src/pool-cl/anti-sniping/CLAntiSniping.sol index a41c1d2..92dcdb3 100644 --- a/src/pool-cl/anti-sniping/CLAntiSniping.sol +++ b/src/pool-cl/anti-sniping/CLAntiSniping.sol @@ -24,6 +24,7 @@ import {SafeCast} from "pancake-v4-core/src/libraries/SafeCast.sol"; /// @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; @@ -35,9 +36,13 @@ contract CLAntiSniping is CLBaseHook { /// @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; @@ -71,8 +76,16 @@ contract CLAntiSniping is CLBaseHook { constructor(ICLPoolManager poolManager, uint128 _positionLockDuration, uint128 _sameBlockPositionsLimit) CLBaseHook(poolManager) { - positionLockDuration = _positionLockDuration; - sameBlockPositionsLimit = _sameBlockPositionsLimit; + 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) { @@ -112,9 +125,9 @@ contract CLAntiSniping is CLBaseHook { (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] = + firstBlockFeesToken0[poolId][positionKey] += FullMath.mulDiv(feeGrowthDelta0X128, info.liquidity, FixedPoint128.Q128); - firstBlockFeesToken1[poolId][positionKey] = + firstBlockFeesToken1[poolId][positionKey] += FullMath.mulDiv(feeGrowthDelta1X128, info.liquidity, FixedPoint128.Q128); } @@ -130,7 +143,7 @@ contract CLAntiSniping is CLBaseHook { BalanceDelta, BalanceDelta, bytes calldata - ) external override returns (bytes4, BalanceDelta) { + ) external override poolManagerOnly returns (bytes4, BalanceDelta) { PoolId poolId = key.toId(); bytes32 positionKey = CLPosition.calculatePositionKey(sender, params.tickLower, params.tickUpper, params.salt); @@ -163,7 +176,7 @@ contract CLAntiSniping is CLBaseHook { PoolKey calldata key, ICLPoolManager.ModifyLiquidityParams calldata params, bytes calldata - ) external override returns (bytes4) { + ) external override poolManagerOnly returns (bytes4) { PoolId poolId = key.toId(); collectLastBlockInfo(poolId); bytes32 positionKey = CLPosition.calculatePositionKey(sender, params.tickLower, params.tickUpper, params.salt); @@ -174,7 +187,7 @@ contract CLAntiSniping is CLBaseHook { liqParams.salt = params.salt; if (positionCreationBlock[poolId][positionKey] != 0 && - block.number - positionCreationBlock[poolId][positionKey] < positionLockDuration) { + block.number - positionCreationBlock[poolId][positionKey] <= positionLockDuration) { revert PositionAlreadyExistsAndLocked(); } if (positionsCreatedInLastBlock[poolId].length >= sameBlockPositionsLimit) revert TooManyPositionsInSameBlock(); @@ -193,11 +206,11 @@ contract CLAntiSniping is CLBaseHook { PoolKey calldata key, ICLPoolManager.ModifyLiquidityParams calldata params, bytes calldata - ) external override returns (bytes4) { + ) 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(); + 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); @@ -208,6 +221,7 @@ contract CLAntiSniping is CLBaseHook { function beforeSwap(address, PoolKey calldata key, ICLPoolManager.SwapParams calldata, bytes calldata) external override + poolManagerOnly returns (bytes4, BeforeSwapDelta, uint24) { PoolId poolId = key.toId(); @@ -220,6 +234,7 @@ contract CLAntiSniping is CLBaseHook { function beforeDonate(address, PoolKey calldata key, uint256, uint256, bytes calldata) external override + poolManagerOnly returns (bytes4) { PoolId poolId = key.toId(); diff --git a/test/pool-cl/CLAntiSniping.t.sol b/test/pool-cl/CLAntiSniping.t.sol index 8d901ee..261567c 100644 --- a/test/pool-cl/CLAntiSniping.t.sol +++ b/test/pool-cl/CLAntiSniping.t.sol @@ -268,7 +268,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { assertEq(antiSniping.firstBlockFeesToken1(poolId, bobPositionKey), 0); // Advance to after position lock duration - vm.roll(POSITION_LOCK_DURATION + 2); + vm.roll(POSITION_LOCK_DURATION + 3); // Bob removes liquidity _decreaseLiquidityPosition(BOB, bobTokenId, 10000 ether, ZERO_BYTES); @@ -332,7 +332,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { assertApproxEqRel(antiSniping.firstBlockFeesToken1(poolId, bobPositionKey), token1Donation / 2, allowedError); // Advance to after position lock duration - vm.roll(POSITION_LOCK_DURATION + 2); + vm.roll(POSITION_LOCK_DURATION + 3); // Bob removes liquidity _decreaseLiquidityPosition(BOB, bobTokenId, 10000 ether, ZERO_BYTES); @@ -378,7 +378,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { assertApproxEqAbsDecimal(antiSniping.firstBlockFeesToken0(poolId, alicePositionKey), token0ExpectedFees, 1e15, 18); // Advance to after position lock duration - vm.roll(POSITION_LOCK_DURATION + 1); + vm.roll(POSITION_LOCK_DURATION + 2); // Alice removes liquidity _decreaseLiquidityPosition(ALICE, aliceTokenId, 10000 ether, ZERO_BYTES); @@ -406,7 +406,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { _increaseLiquidityPosition(ALICE, aliceTokenId, 10000 ether, ZERO_BYTES); // Advance to after position lock duration - vm.roll(POSITION_LOCK_DURATION + 1); + vm.roll(POSITION_LOCK_DURATION + 3); // Alice add liquidity _increaseLiquidityPosition(ALICE, aliceTokenId, 10000 ether, ZERO_BYTES); @@ -419,7 +419,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { uint256 token0ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; // Swap amount * fee percentage // Advance to after position lock duration - vm.roll(POSITION_LOCK_DURATION + 3); + vm.roll(POSITION_LOCK_DURATION + 4); _performSwapExactOutputSingle(CANDY, false, swapAmount); uint256 token1ExpectedFees = (uint256(swapAmount) * FEE) / 1e6; @@ -439,7 +439,7 @@ contract AntiSnipingTest is Test, Deployers, DeployPermit2 { _decreaseLiquidityPosition(ALICE, aliceTokenId, 20000 ether, ZERO_BYTES); // Advance to after position lock duration - vm.roll(POSITION_LOCK_DURATION + POSITION_LOCK_DURATION + 2); + vm.roll(POSITION_LOCK_DURATION + POSITION_LOCK_DURATION + 5); // Attempt to remove liquidity partially and expect revert vm.expectRevert();