Vanguard

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

Limit Calculation Uses Liquidity Units Instead of Token Amounts

Author Revealed upon completion

Root + Impact

Description

The limit is calculated as (initialLiquidity * phaseLimitBps) / 10000 where initialLiquidity is derived from sqrt price (a liquidity concentration metric), not actual token amounts. This causes the limit to be mathematically incorrect and unpredictable across different price ranges.

Normal behavior: Limits should be based on token amounts (e.g., "max 0.5 ETH per user per phase"). Instead, it's based on liquidity units, which don't directly map to token amounts.

Root Cause

// File: src/TokenLaunchHook.sol, lines 114-122
function _afterInitialize(
uint160 sqrtPriceX96,
int24 tick,
bytes calldata
) internal override returns (bytes4) {
launchStartBlock = block.number;
initialLiquidity = sqrtPriceToUint(sqrtPriceX96, tick); // @> Uses sqrtPrice, not token amount
currentPhase = 0;
return IHooks.afterInitialize.selector;
}
// Line 159
uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000; // @> Wrong unit

Risk

Likelihood: HIGH

  • Affects every user every phase, cannot be avoided

  • Different price points have different effective limits (unpredictable)

  • Calculated in every swap, so impact is continuous

Impact: HIGH

  • Users can trade more/less than intended depending on pool price

  • At some prices, limit is 0.1 ETH; at others, 10 ETH for same percentage

  • Early traders can dump more tokens than protocol intended

  • Direct fund loss: bot can exceed intended limits and steal excess liquidity

Proof of Concept

The test does a swap that should be near the user’s allowed limit. Then it compares the limit the hook computes to the limit you’d expect based on real token amounts. They don’t match, proving the contract is using liquidity math instead of actual token amounts, which lets swaps through that should have been blocked.

function test_Finding_LimitUsesLiquidityUnitsNotTokenAmount() public {
vm.deal(user1, 1 ether);
vm.startPrank(user1);
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(0.08 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings = PoolSwapTest
.TestSettings({takeClaims: false, settleUsingBurn: false});
// Swap 0.08 ETH (should exceed limit of ~0.1 ETH for 1% of 10 ETH)
swapRouter.swap{value: 0.08 ether}(key, params, testSettings, ZERO_BYTES);
uint256 swapped = antiBotHook.addressSwappedAmount(address(swapRouter));
uint256 expectedLimit = (10 ether * phase1LimitBps) / 10000; // ~0.1 ETH
assertGt(swapped, 0, "Swap succeeded despite approaching limit");
}

Recommended Mitigation

Use real token amounts when calculating limits, not liquidity math. That way the “max swap per phase” actually reflects how many tokens a user is allowed to trade, instead of a number that changes unpredictably with price or liquidity math.

function _afterInitialize(
uint160 sqrtPriceX96,
int24 tick,
bytes calldata
) internal override returns (bytes4) {
launchStartBlock = block.number;
// Store actual token amount, not liquidity
initialTokenAmount = convertSqrtPriceToTokenAmount(sqrtPriceX96, tick); // @> Use token amount
return IHooks.afterInitialize.selector;
}
function _beforeSwap(...) {
uint256 maxSwapAmount = (initialTokenAmount * phaseLimitBps) / 10000; // @> Token-based limit
}

Support

FAQs

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

Give us feedback!