Root + Impact
Description
-
The TokenLaunchHook calculates per-address swap limits based on initialLiquidity, which is set once during pool initialization and only updated if it becomes zero during beforeSwap. This mechanism is designed to prevent bots from executing large swaps that could manipulate the token price during the launch phases.
-
The hook never updates initialLiquidity when liquidity providers add or remove liquidity from the pool after initialization. An attacker can manipulate their effective swap limits by first removing liquidity (reducing pool liquidity), executing swaps that would normally exceed limits, then re-adding liquidity to restore the pool state. This completely bypasses the anti-bot protection.
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity);
}
uint256 phaseLimitBps = currentPhase == 1 ? phase1LimitBps : phase2LimitBps;
uint256 phaseCooldown = currentPhase == 1 ? phase1Cooldown : phase2Cooldown;
uint256 phasePenaltyBps = currentPhase == 1 ? phase1PenaltyBps : phase2PenaltyBps;
uint256 swapAmount =
params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);
uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
}
Risk
Likelihood:
-
Token creators launch with minimal liquidity and have unlimited token supply to manipulate pool state at will
-
Attackers with sufficient capital can use flash loans to temporarily provide/remove liquidity without permanent capital commitment
-
Natural liquidity changes by legitimate LPs create windows where attackers can exploit the stale initialLiquidity reference
Impact:
-
Complete bypass of phase-based swap limits allows bots to execute arbitrarily large swaps during protected launch phases
-
Attackers can front-run legitimate users by manipulating liquidity to enable large swaps, then restoring pool state
-
The anti-bot protection mechanism becomes ineffective, enabling the exact price manipulation and sandwich attacks it was designed to prevent
Proof of Concept
Demonstrates that initialLiquidity is only set once and never updates. This allows an attacker to manipulate limits by changing pool liquidity
function test_PROOF_LiquidityManipulationBypassesLimits() public {
assertEq(antiBotHook.initialLiquidity(), 0, "initialLiquidity starts at 0");
vm.deal(user1, 1 ether);
vm.startPrank(user1);
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(0.01 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings = PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
swapRouter.swap{value: 0.01 ether}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 cachedLiquidity = antiBotHook.initialLiquidity();
assertGt(cachedLiquidity, 0, "initialLiquidity should be cached after first swap");
vm.deal(user2, 1 ether);
vm.startPrank(user2);
swapRouter.swap{value: 0.01 ether}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 liquidityAfterSecondSwap = antiBotHook.initialLiquidity();
assertEq(liquidityAfterSecondSwap, cachedLiquidity,
"initialLiquidity never updates - it's cached permanently");
console.log("Cached liquidity that never updates:", cachedLiquidity);
console.log("All limit calculations use this stale value forever");
}
Recommended Mitigation
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
- if (initialLiquidity == 0) {
- uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
- initialLiquidity = uint256(liquidity);
- }
+ // Always use current pool liquidity for limit calculations
+ uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
+ uint256 currentLiquidity = uint256(liquidity);
+ if (currentLiquidity == 0) revert PoolNotInitialized();
[...]
uint256 phaseLimitBps = currentPhase == 1 ? phase1LimitBps : phase2LimitBps;
uint256 phaseCooldown = currentPhase == 1 ? phase1Cooldown : phase2Cooldown;
uint256 phasePenaltyBps = currentPhase == 1 ? phase1PenaltyBps : phase2PenaltyBps;
uint256 swapAmount =
params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);
- uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
+ uint256 maxSwapAmount = (currentLiquidity * phaseLimitBps) / 10000;