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,