Vanguard

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

Stale Liquidity Snapshot Causes Unfairly Restrictive Swap Limits (Permanent Throttle)

Author Revealed upon completion

ROOT + IMPACT

## Description

The `TokenLaunchHook` calculates swap limits based on a percentage of `initialLiquidity`. This variable is set via a "Lazy Load" mechanism inside `_beforeSwap`: if `initialLiquidity` is 0, it fetches the current pool liquidity and saves it.

**The Critical Flaw:**

This logic executes **only once**. Once `initialLiquidity` is set to a non-zero value, it is never updated again. The hook fails to account for any subsequent liquidity additions.

If a pool starts with low liquidity (e.g., 1 ETH) and later grows significantly (e.g., to 1,000 ETH), the swap limits will remain calculated based on the original 1 ETH. This effectively creates a "Permanent Throttle," locking the swap limit to a tiny amount forever, rendering the pool unusable as it scales.

```solidity

// @audit works for the first swap only. Never updates again.

if (initialLiquidity == 0) {

uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());

initialLiquidity = uint256(liquidity);

}

```

## Risk

**Likelihood**: High (Certainty)

* Standard pool lifecycles involve adding liquidity over time. This logic guarantees the hook uses outdated data.

**Impact**: High

* **Protocol Broken / DoS:** As the pool grows, the effective swap limit (relative to total liquidity) shrinks toward 0%.

* **Example:**

* Initial: 10 ETH. Limit (1%) = 0.1 ETH.

* New Liquidity: +990 ETH. Total = 1,000 ETH.

* Actual Limit: Still 0.1 ETH.

* Correct Limit should be: 10 ETH.

* **Result:** Users can only trade 0.01% of the pool, making it functionally dead.


\

## Proof of Concept

Add this test to your `TestTokenLaunchHook` contract. It proves that adding massive liquidity does not increase the `initialLiquidity` variable.

```solidity

function test_PoC_StaleLiquidity_DoesNotUpdate() public {

// 1. SETUP: Start with small liquidity (defined in setUp, e.g., 10 ETH)

// 2. ACTION: Trigger the 'Lazy Load' with a tiny swap

// This locks 'initialLiquidity' to the starting amount (10 ETH)

vm.deal(user1, 1 ether);

SwapParams memory params = SwapParams({

zeroForOne: true,

amountSpecified: -100, // Tiny amount

sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1

});

PoolSwapTest.TestSettings memory testSettings =

PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});

vm.startPrank(user1);

swapRouter.swap{value: 100 wei}(key, params, testSettings, ZERO_BYTES);

vm.stopPrank();

// Capture the locked value

uint256 lockedLiquidity = antiBotHook.initialLiquidity();

console.log("Liquidity Locked at:", lockedLiquidity);

// 3. ACTION: Add MASSIVE liquidity (1,000 ETH)

uint256 ethToAdd = 1000 ether;

// Calculate the liquidity delta for 1000 ETH at current price

uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);

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

);

// 4. ASSERTION: Verify the hook ignores the new liquidity

// We can check this by performing a swap that SHOULD be valid under new liquidity

// but fails under the old locked liquidity.

// Old Limit (1% of 10 ETH) = 0.1 ETH

// New Limit (1% of 1010 ETH) = 10.1 ETH

// We try to swap 1 ETH.

uint256 swapAmount = 1 ether;

params.amountSpecified = -int256(swapAmount);

// The hook doesn't revert, but it applies a penalty if over limit.

// We can inspect the public variable 'initialLiquidity' to prove it hasn't changed.

uint256 currentHookLiquidity = antiBotHook.initialLiquidity();

console.log("Current Pool Liquidity: ~1010 ETH");

console.log("Hook Stored Liquidity: ", currentHookLiquidity);

assertEq(currentHookLiquidity, lockedLiquidity, "FAIL: Hook did not update liquidity after addition");

assertLt(currentHookLiquidity, 100 ether, "FAIL: Hook is using stale data");

}

```

## Recommended Mitigation

Do not cache the liquidity in a state variable. Instead, fetch the **current** liquidity dynamically from the StateLibrary during every swap calculation. This ensures the limit is always 1% (or 3%) of the *actual* available liquidity.

```diff

function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)

internal

override

returns (bytes4, BeforeSwapDelta, uint24)

{

if (launchStartBlock == 0) revert PoolNotInitialized();

// REMOVE THIS BLOCK

- if (initialLiquidity == 0) {

- uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());

- initialLiquidity = uint256(liquidity);

- }

// ... phase calculation ...

// ADD THIS: Fetch current liquidity dynamically

+ uint256 currentLiquidity = uint256(StateLibrary.getLiquidity(poolManager, key.toId()));

// Update calculation to use currentLiquidity

- 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!