Root + Impact
Description
The initialLiquidity state variable is captured during afterInitialize (line 122-124) when the pool is first initialized. However, in Uniswap V4, pool initialization and liquidity provision are separate transactions. If the pool is initialized with zero liquidity (common pattern), initialLiquidity remains 0. The hook then defers capture to the first swap (line 132-135), reading current liquidity at that moment. An attacker can front-run the project's main liquidity addition by adding minimal liquidity (1 wei) and executing a tiny swap, permanently pinning initialLiquidity to a near-zero value. This collapses the maxSwapAmount calculation (initialLiquidity * phaseLimitBps) / 10000 to effectively zero, forcing 100% penalties on all subsequent legitimate trades.
function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
launchStartBlock = block.number;
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity);
}
if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity);
}
uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
Risk
Likelihood:
High - Standard deployment pattern initializes pool before adding liquidity. afterInitialize typically captures 0 liquidity, triggering the fallback logic at first swap. MEV bots monitor mempool for initialize calls and can front-run liquidity additions with minimal gas cost.
Impact:
High - Permanent griefing of launch dynamics:
Example scenario: Project intends 10 ETH liquidity → 33.385 ETH-equivalent limit (at 1% phase1LimitBps)
Attacker action: Adds 1000 wei liquidity + minimal swap
Result: initialLiquidity = 1000, maxSwapAmount = 0.0000000000000001 ETH (effectively 0)
User impact: All trades exceed limit → 50% (Phase 1) or 20% (Phase 2) penalties applied universally
No recovery: initialLiquidity has no setter; contract must be redeployed
Proof of Concept
Demonstrates that capturing liquidity at first swap (when initialLiquidity=0) allows an attacker to permanently set a negligible value, collapsing all trade limits.
(It seems my PoC facing under/over flow error, but the bug persist)
function test_InitialLiquidityGriefing() public {
address bot = address(0xDEAD);
address victim = address(0x1234);
vm.deal(bot, 20 ether);
vm.deal(victim, 1000 ether);
MockERC20 tokenA = new MockERC20("GRIEF", "GRF", 18);
MockERC20 tokenB = new MockERC20("WETH", "WETH", 18);
tokenA.mint(bot, 20 ether);
tokenB.mint(bot, 20 ether);
(PoolKey memory poolKey,) = initPool(Currency.wrap(address(tokenA)), Currency.wrap(address(tokenB)), hook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1);
vm.startPrank(bot);
tokenA.approve(address(modifyLiquidityRouter), type(uint256).max);
tokenB.approve(address(modifyLiquidityRouter), type(uint256).max);
tokenA.approve(address(swapRouter), type(uint256).max);
tokenB.approve(address(swapRouter), type(uint256).max);
modifyLiquidityRouter.modifyLiquidity(poolKey, ModifyLiquidityParams({tickLower: -60, tickUpper: 60, liquidityDelta: int256(uint256(1e18)), salt: bytes32(0)}), ZERO_BYTES);
swapRouter.swap(poolKey, SwapParams({zeroForOne: true, amountSpecified: -10**16, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), ZERO_BYTES);
vm.stopPrank();
assertEq(hook.initialLiquidity(), 1e18);
tokenA.mint(address(this), 1000000 ether);
tokenA.approve(address(modifyLiquidityRouter), type(uint256).max);
modifyLiquidityRouter.modifyLiquidity(poolKey, ModifyLiquidityParams({tickLower: -60, tickUpper: 60, liquidityDelta: 10**30, salt: bytes32(0)}), ZERO_BYTES);
vm.startPrank(victim);
tokenA.mint(victim, 1000 ether);
tokenA.approve(address(swapRouter), type(uint256).max);
swapRouter.swap(poolKey, SwapParams({zeroForOne: true, amountSpecified: -1 ether, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}), PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), ZERO_BYTES);
vm.stopPrank();
}
Recommended Mitigation
Implement a multi-block seeding window or owner-controlled liquidity confirmation. Track maximum observed liquidity during a liquiditySeedingDuration window after launch, then lock the highest value as initialLiquidity. Alternatively, require owner to explicitly set initialLiquidity after sufficient liquidity is added.
error OnlyOwner();
error InitialLiquidityAlreadySet();
error LiquiditySeedPeriodExpired();
bool public initialLiquiditySet;
function setInitialLiquidity(PoolKey calldata key) external {
if (msg.sender != owner) revert OnlyOwner();
if (initialLiquiditySet) revert InitialLiquidityAlreadySet();
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
if (liquidity == 0) revert NoLiquidityInPool();
initialLiquidity = uint256(liquidity);
initialLiquiditySet = true;
launchStartBlock = block.number;
emit InitialLiquiditySet(liquidity, block.number);
}
function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
return BaseHook.afterInitialize.selector;
}
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal override returns (bytes4, BeforeSwapDelta, uint24)
{
if (!initialLiquiditySet) revert InitialLiquidityNotSet();
}