-
Notifications
You must be signed in to change notification settings - Fork 17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Add anti-sniping hook contract #20
base: main
Are you sure you want to change the base?
Changes from all commits
9596ab2
fbca282
7468dd4
02a9a80
d77eff9
4de9c87
f2a92f9
b54b2b9
f646684
aa73e7d
de9289d
52a5880
6f4bec3
aa7f1ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
AntiSnipingTest:testDonationSnipingPrevention() (gas: 1221314) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
AntiSnipingTest:testFeeRedistributionWhenNoLiquidity() (gas: 783889) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
AntiSnipingTest:testSwapAfterIncreaseLiquidity() (gas: 1079845) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
AntiSnipingTest:testSwapFeeSnipingPrevention() (gas: 1309635) |
+193 −0 | CONTRIBUTING.md | |
+17 −1 | README.md | |
+1 −1 | package.json | |
+12 −1 | scripts/vm.py | |
+1 −1 | src/StdCheats.sol | |
+104 −0 | src/StdJson.sol | |
+104 −0 | src/StdToml.sol | |
+271 −18 | src/Vm.sol | |
+471 −463 | src/console.sol | |
+2 −2 | src/interfaces/IERC4626.sol | |
+2 −3 | test/StdChains.t.sol | |
+9 −6 | test/Vm.t.sol |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
ChefCupcake marked this conversation as resolved.
Show resolved
Hide resolved
|
||
); | ||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. any reason why we are not doing the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same question here There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. because only mint and remove all liquidity are allowed, so tokenId will not be repeat in this scenario for extra gas costs,but yes i can add delete in this func |
||
} | ||
|
||
/// @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]; | ||
ChefCupcake marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can u add some test cases so that we can compare the gas snapshot ? I am a bit curious that how many gas overhead will this function bring There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, will add testcase later |
||
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; | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we add a disclaimer here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok, added