Vanguard

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

Missing Pool Isolation in Global State Tracking

Author Revealed upon completion

Root + Impact

Uniswap V4 hooks are designed to support attachment to multiple pools simultaneously, with each pool maintaining independent state. The TokenLaunchHook stores launch timing, liquidity snapshots, and phase state in global variables without scoping them to individual pools.

The hook corrupts protection logic when attached to multiple pools because initialization of one pool overwrites the launch state of previously initialized pools, causing incorrect phase calculations and limit enforcement across all pools.

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

Risk

Likelihood: High
Reason 1: Uniswap V4's architecture explicitly encourages hook reuse across multiple pools—projects commonly attach the same hook instance to related token pairs (e.g., TOKEN/ETH and TOKEN/USDC during launches).
Reason 2: The hook provides no warnings or validation preventing multi-pool attachment, creating a silent failure mode where protection appears functional but is critically broken.

Impact: High
Impact 1: Phase calculations become completely unreliable—swaps on Pool A may be evaluated against Pool B's launch timeline, causing premature phase transitions or permanent Phase 1 enforcement.
Impact 2: Swap limits miscalculated using wrong liquidity snapshots (e.g., 5% of Pool B's liquidity applied to Pool A swaps), enabling attackers to bypass intended protections or legitimate users to be incorrectly blocked.

Proof of Concept

// Deployment: Single TokenLaunchHook instance attached to two pools
// Pool A: LAUNCH/ETH (target token launch)
// Pool B: LAUNCH/USDC (secondary pair)
// Block 1000: Pool A initialized
// → launchStartBlock = 1000
// → initialLiquidity = 1,000,000 (Pool A liquidity)
// → currentPhase = 1
// Block 1050: Attacker swaps 50,000 LAUNCH on Pool A (5% limit)
// → Within Phase 1 limits ✅
// Block 2000: Pool B initialized (legitimate secondary pair)
// → launchStartBlock OVERWRITTEN to 2000
// → initialLiquidity OVERWRITTEN to 100,000 (Pool B liquidity)
// → currentPhase reset to 1 for BOTH pools
// Block 2001: Attacker swaps on Pool A again
// → blocksSinceLaunch = 2001 - 2000 = 1 block (thinks it's Phase 1 start)
// → maxSwapAmount = 100,000 * 5% = 5,000 (using Pool B's liquidity!)
// → Actual swap: 50,000 LAUNCH
// → 50,000 > 5,000 → penalty applies (incorrectly)
//
// OR with different config:
// → Attacker swaps 4,000 LAUNCH (under miscalculated 5,000 limit)
// → NO penalty applied despite exceeding 0.4% of actual Pool A liquidity
// → Protection completely bypassed

Recommended Mitigation

- uint256 public launchStartBlock;
- uint256 public initialLiquidity;
- uint256 public currentPhase;
- uint256 public lastPhaseUpdateBlock;
+ mapping(PoolId => uint256) public launchStartBlock;
+ mapping(PoolId => uint256) public initialLiquidity;
+ mapping(PoolId => uint256) public currentPhase;
+ mapping(PoolId => uint256) public lastPhaseUpdateBlock;
- mapping(address => uint256) public addressSwappedAmount;
- mapping(address => uint256) public addressLastSwapBlock;
+ mapping(PoolId => mapping(address => uint256)) public addressSwappedAmount;
+ mapping(PoolId => mapping(address => uint256)) public addressLastSwapBlock;
function _afterInitialize(address, PoolKey calldata key, uint160, int24)
internal override
returns (bytes4)
{
PoolId poolId = key.toId();
+ if (launchStartBlock[poolId] != 0) {
+ revert PoolAlreadyInitialized();
+ }
launchStartBlock[poolId] = block.number;
uint128 liquidity = StateLibrary.getLiquidity(poolManager, poolId);
initialLiquidity[poolId] = uint256(liquidity);
currentPhase[poolId] = 1;
lastPhaseUpdateBlock[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[poolId] == 0) revert PoolNotInitialized();
- uint256 blocksSinceLaunch = block.number - launchStartBlock;
+ uint256 blocksSinceLaunch = block.number - launchStartBlock[poolId];
// ... rest of logic uses poolId-scoped state
- uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
+ uint256 maxSwapAmount = (initialLiquidity[poolId] * phaseLimitBps) / 10000;
- addressSwappedAmount[sender] += swapAmount;
+ addressSwappedAmount[poolId][sender] += swapAmount;
// ...
}

Support

FAQs

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

Give us feedback!