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.
@> 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;
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;
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
@> initialLiquidity = uint256(liquidity);
@> currentPhase = 1;
@> lastPhaseUpdateBlock = block.number;
return BaseHook.afterInitialize.selector;
}
Risk
Likelihood:
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
A new pool is created (Pool A)
Pool A progresses to phase 2, and swaps occur through the pool
A new pool (Pool B) is initialized
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 {
uint256 poolALaunchBlock = antiBotHook.launchStartBlock();
SwapParams memory buyParams = SwapParams({
zeroForOne: true,
amountSpecified: -int256(1 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
vm.deal(user1, 1 ether);
vm.startPrank(user1);
swapRouter.swap{value: 1 ether}(key, buyParams, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 poolALiquidity = antiBotHook.initialLiquidity();
console.log("Pool A liquidity:", poolALiquidity);
vm.roll(block.number + phase1Duration + 1);
vm.warp(block.timestamp + phase1Duration + 1);
uint256 poolAPhase = antiBotHook.getCurrentPhase();
console.log("Pool A phase:", poolAPhase);
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));
(PoolKey memory keyB,) = initPool(
ethCurrency,
token2Currency,
antiBotHook,
LPFeeLibrary.DYNAMIC_FEE_FLAG,
SQRT_PRICE_1_1_s
);
uint256 newLaunchBlock = antiBotHook.launchStartBlock();
uint256 newLiquidity = antiBotHook.initialLiquidity();
uint256 newPhase = antiBotHook.getCurrentPhase();
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;
- }