Vanguard

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

Shared state across all pools causes new pool initialization to break existing pools

Author Revealed upon completion

Shared state across all pools causes new pool initialization to break existing pools

Description

The TokenLaunchHook contract uses single state variables for pool-specific data instead of mappings keyed by PoolId. In Uniswap V4, hooks are singleton contracts - multiple pools use the same hook instance. When a new pool initializes with this hook, it overwrites the state for all previously initialized pools, completely breaking their launch protection configuration.

/* ™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™ */
/* STATE VARIABLES */
/* ™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™™ */
@> uint256 public currentPhase; // Shared across ALL pools
@> uint256 public lastPhaseUpdateBlock; // Shared across ALL pools
@> uint256 public launchStartBlock; // Shared across ALL pools
@> uint256 public initialLiquidity; // Shared across ALL pools
@> uint256 public totalPenaltyFeesCollected;
@> mapping(address => uint256) public addressSwappedAmount; // Not keyed by pool
@> mapping(address => uint256) public addressLastSwapBlock; // Not keyed by pool
@> mapping(address => uint256) public addressTotalSwaps; // Not keyed by pool
@> mapping(address => uint256) public addressPenaltyCount; // Not keyed by pool

When TokenLaunchHook::_afterInitialize is called for Pool B after Pool A is already running:

function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
@> launchStartBlock = block.number; // Overwrites Pool A's launch block
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
@> initialLiquidity = uint256(liquidity); // Overwrites Pool A's liquidity
@> currentPhase = 1; // Resets Pool A back to Phase 1
@> lastPhaseUpdateBlock = block.number; // Overwrites Pool A's tracking
return BaseHook.afterInitialize.selector;
}

Risk

Likelihood:

  • This occurs every time a new pool is created using this hook

  • The hook is designed as a reusable component ("anti-bot protection for token launches" - plural)

Impact:

  • Whenever a new pool is initialized, all pools previously initialized use the state set by the latest initialization

  • Phase timing is corrupted - pools that had progressed to phase 2 or 3 are reset back to phase 1

  • initialLiquidity changes to the latest initialized pool's value

  • Heavily impacts swappers as they are exposed to paying penalty fees in pools that should be in phase 3

Proof of Concept

  1. A new pool is created (Pool A)

  2. Pool A progresses to phase 2, and swaps occur through the pool

  3. A new pool (Pool B) is initialized

  4. Pool A is now using the state of Pool B, for currentPhase, launchStartBlock etc.

Add the following test to TokenLaunchHookUnit.t.sol:

function test_SecondPoolBreaksFirstPool() public {
// Pool A is already initialized in setUp()
uint256 poolALaunchBlock = antiBotHook.launchStartBlock();
// Create buy swap params
SwapParams memory buyParams = SwapParams({
zeroForOne: true,
amountSpecified: -int256(1 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
// Create test settings
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
// Deal user buy funds
vm.deal(user1, 1 ether);
// Execute buy swap
vm.startPrank(user1);
swapRouter.swap{value: 1 ether}(key, buyParams, testSettings, ZERO_BYTES);
vm.stopPrank();
// Get pool A's initial liquidity, its set after the swap
uint256 poolALiquidity = antiBotHook.initialLiquidity();
console.log("Pool A liquidity:", poolALiquidity);
// Advance some blocks - Pool A progresses
vm.roll(block.number + phase1Duration + 1);
vm.warp(block.timestamp + phase1Duration + 1);
// Get pool A's current phase, should be phase 2
uint256 poolAPhase = antiBotHook.getCurrentPhase();
console.log("Pool A phase:", poolAPhase);
// Create a second token and pool using the SAME hook
MockERC20 token2 = new MockERC20("TOKEN2", "TKN2", 18);
token2.mint(address(this), 1000 ether);
token2.approve(address(modifyLiquidityRouter), type(uint256).max);
Currency token2Currency = Currency.wrap(address(token2));
// Initialize Pool B with the same hook
(PoolKey memory keyB,) = initPool(
ethCurrency,
token2Currency,
antiBotHook,
LPFeeLibrary.DYNAMIC_FEE_FLAG,
SQRT_PRICE_1_1_s
);
// Pool A's state has been completely overwritten
uint256 newLaunchBlock = antiBotHook.launchStartBlock();
uint256 newLiquidity = antiBotHook.initialLiquidity();
uint256 newPhase = antiBotHook.getCurrentPhase();
// All of Pool A's state is now Pool B's state
assertGt(newLaunchBlock, poolALaunchBlock, "Launch block was overwritten");
assertNotEq(newPhase, poolAPhase, "Phase reset to 1, breaking Pool A's progression");
assertNotEq(newLiquidity, poolALiquidity, "Initial liquidity was overwritten");
}

Recommended Mitigation

Convert all pool-specific state to mappings keyed by PoolId:

Note: The TokenLaunchHook::_resetPerAddressTracking has been removed in this solution, as the existing implementation was incorrect, and it can be replaced by this updated mapping.

- uint256 public currentPhase;
- uint256 public lastPhaseUpdateBlock;
- uint256 public launchStartBlock;
- uint256 public initialLiquidity;
- uint256 public totalPenaltyFeesCollected;
+ mapping(PoolId => uint256) public currentPhase;
+ mapping(PoolId => uint256) public lastPhaseUpdateBlock;
+ mapping(PoolId => uint256) public launchStartBlock;
+ mapping(PoolId => uint256) public initialLiquidity;
+ mapping(PoolId => uint256) public totalPenaltyFeesCollected;
- mapping(address => uint256) public addressSwappedAmount;
- mapping(address => uint256) public addressLastSwapBlock;
- mapping(address => uint256) public addressTotalSwaps;
- mapping(address => uint256) public addressPenaltyCount;
+ // Keyed by: PoolId => phase => address => amount
+ mapping(PoolId => mapping(uint256 => mapping(address => uint256))) public addressSwappedAmount;
+ mapping(PoolId => mapping(uint256 => mapping(address => uint256))) public addressLastSwapBlock;
+ mapping(PoolId => mapping(uint256 => mapping(address => uint256))) public addressTotalSwaps;
+ mapping(PoolId => mapping(uint256 => mapping(address => uint256))) public addressPenaltyCount;

Update TokenLaunchHook::_afterInitialize to use pool-specific storage:

function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
+ PoolId id = key.toId();
- launchStartBlock = block.number;
+ launchStartBlock[id] = block.number;
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
- initialLiquidity = uint256(liquidity);
+ initialLiquidity[id] = uint256(liquidity);
- currentPhase = 1;
+ currentPhase[id] = 1;
- lastPhaseUpdateBlock = block.number;
+ lastPhaseUpdateBlock[id] = block.number;
return BaseHook.afterInitialize.selector;
}

Update TokenLaunchHook::_beforeSwap to use pool-specific storage:

function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
- if (launchStartBlock == 0) revert PoolNotInitialized();
+ PoolId id = key.toId();
+ if (launchStartBlock[id] == 0) revert PoolNotInitialized();
- if (initialLiquidity == 0) {
+ if (initialLiquidity[id] == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
- initialLiquidity = uint256(liquidity);
+ initialLiquidity[id] = uint256(liquidity);
}
- uint256 blocksSinceLaunch = block.number - launchStartBlock;
+ uint256 blocksSinceLaunch = block.number - launchStartBlock[id];
uint256 newPhase;
if (blocksSinceLaunch < phase1Duration) {
newPhase = 1;
} else if (blocksSinceLaunch < phase1Duration + phase2Duration) {
newPhase = 2;
} else {
newPhase = 3;
}
- if (newPhase != currentPhase) {
- _resetPerAddressTracking();
- currentPhase = newPhase;
- lastPhaseUpdateBlock = block.number;
+ if (newPhase != currentPhase[id]) {
+ currentPhase[id] = newPhase;
+ lastPhaseUpdateBlock[id] = block.number;
}
- if (currentPhase == 3) {
+ if (currentPhase[id] == 3) {
return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, LPFeeLibrary.OVERRIDE_FEE_FLAG);
}
- uint256 phaseLimitBps = currentPhase == 1 ? phase1LimitBps : phase2LimitBps;
- uint256 phaseCooldown = currentPhase == 1 ? phase1Cooldown : phase2Cooldown;
- uint256 phasePenaltyBps = currentPhase == 1 ? phase1PenaltyBps : phase2PenaltyBps;
+ uint256 phaseLimitBps = currentPhase[id] == 1 ? phase1LimitBps : phase2LimitBps;
+ uint256 phaseCooldown = currentPhase[id] == 1 ? phase1Cooldown : phase2Cooldown;
+ uint256 phasePenaltyBps = currentPhase[id] == 1 ? phase1PenaltyBps : phase2PenaltyBps;
uint256 swapAmount =
params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);
- uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
+ uint256 maxSwapAmount = (initialLiquidity[id] * phaseLimitBps) / 10000;
bool applyPenalty = false;
- if (addressLastSwapBlock[sender] > 0) {
- uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[sender];
+ if (addressLastSwapBlock[poolId][currentPhase][sender] > 0) {
+ uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[poolId][currentPhase][sender];
if (blocksSinceLastSwap < phaseCooldown) {
applyPenalty = true;
}
}
- if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
+ if (!applyPenalty && addressSwappedAmount[poolId][currentPhase][sender] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
- addressSwappedAmount[sender] += swapAmount;
- addressLastSwapBlock[sender] = block.number;
+ addressSwappedAmount[poolId][currentPhase][sender] += swapAmount;
+ addressLastSwapBlock[poolId][currentPhase][sender] = block.number;
uint24 feeOverride = 0;
if (applyPenalty) {
feeOverride = uint24((phasePenaltyBps * 100));
}
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}
- function _resetPerAddressTracking() internal {
- addressSwappedAmount[address(0)] = 0;
- addressLastSwapBlock[address(0)] = 0;
- }

Support

FAQs

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

Give us feedback!