Vanguard

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

Liquidity manipulation

Author Revealed upon completion

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();
// @> Only updates initialLiquidity if it's zero, not when liquidity changes
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);
// @> maxSwapAmount is based on stale initialLiquidity value
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 {
// Initially, initialLiquidity is 0 (not set until first swap)
assertEq(antiBotHook.initialLiquidity(), 0, "initialLiquidity starts at 0");
// Do first swap - this triggers initialLiquidity to be set
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");
// Do a second swap - initialLiquidity should NOT change
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;

Support

FAQs

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

Give us feedback!