Vanguard

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

Invalid Unit Comparison Between Liquidity and Token Amount Breaks Swap Limits

Author Revealed upon completion

Root + Impact

The hook computes maxSwapAmount from pool liquidity and compares it directly with token-denominated swap amounts. Liquidity and token amounts are different units, so the limit becomes too strict for one side or too loose for the other, weakening the intended anti-bot protection and applying penalties inconsistently.

Description

  • The normal behavior should cap each address based on a percentage of token reserves, or an equivalent token amount derived from pool state.

  • The current logic uses initialLiquidity (liquidity units) and compares it to swapAmount (token units), so the cap does not represent a real percentage of pool token balances.

uint256 swapAmount =
params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);
@> uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
@> if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}

Risk

Likelihood:

  • Occurs when the pool price is not 1:1 and token decimals differ across the pair

Impact:

The penalty threshold no longer reflects an intended percentage of reserves, so legitimate swaps can be penalized too early or large swaps can avoid the intended penalty during launch phases.

vulnerability Path

Steps:

  1. Initialize a pool with a non-1:1 price and mixed decimals

  2. Add liquidity around the current price

  3. Hook records initialLiquidity in liquidity units

  4. User swaps token1 while the cap is computed from liquidity units

  5. The computed cap diverges from the token1 reserve percentage

  6. Penalties are applied too early or too late compared to the intended limit

Proof of Concept

The test creates an imbalanced ETH/USDC pool, derives token-based limits from pool state, and shows a material divergence from the liquidity-based limit used by the hook.

// test/Finding4_LiquidityMismatchReal.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
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 {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol";
import {AmountHelpers} from "@uniswap/v4-core/test/utils/AmountHelpers.sol";
import {StateLibrary} from "v4-core/libraries/StateLibrary.sol";
contract Finding4_LiquidityMismatchReal is Test, Deployers {
using CurrencyLibrary for Currency;
MockERC20 token;
TokenLaunchHook public antiBotHook;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
uint256 constant PHASE1_DURATION = 100;
uint256 constant PHASE2_DURATION = 200;
uint256 constant PHASE1_LIMIT_BPS = 100;
uint256 constant PHASE2_LIMIT_BPS = 300;
uint256 constant PHASE1_COOLDOWN = 5;
uint256 constant PHASE2_COOLDOWN = 3;
uint256 constant PHASE1_PENALTY_BPS = 500;
uint256 constant PHASE2_PENALTY_BPS = 200;
function setUp() public {
deployFreshManagerAndRouters();
token = new MockERC20("USDC", "USDC", 6);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 1_000_000_000 * 1e6);
token.approve(address(swapRouter), type(uint256).max);
token.approve(address(modifyLiquidityRouter), type(uint256).max);
}
function test_LiquidityLimitMismatch_ForImbalancedPrice() public {
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory constructorArgs = abi.encode(
manager, PHASE1_DURATION, PHASE2_DURATION,
PHASE1_LIMIT_BPS, PHASE2_LIMIT_BPS,
PHASE1_COOLDOWN, PHASE2_COOLDOWN,
PHASE1_PENALTY_BPS, PHASE2_PENALTY_BPS
);
uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG);
(address hookAddress, bytes32 salt) = HookMiner.find(address(this), flags, creationCode, constructorArgs);
antiBotHook = new TokenLaunchHook{salt: salt}(
manager, PHASE1_DURATION, PHASE2_DURATION,
PHASE1_LIMIT_BPS, PHASE2_LIMIT_BPS,
PHASE1_COOLDOWN, PHASE2_COOLDOWN,
PHASE1_PENALTY_BPS, PHASE2_PENALTY_BPS
);
require(address(antiBotHook) == hookAddress, "Hook address mismatch");
// Create a pool at an imbalanced price to amplify unit mismatch.
int24 targetTick = 90_000;
uint160 sqrtPriceX96 = TickMath.getSqrtPriceAtTick(targetTick);
(key,) = initPool(ethCurrency, tokenCurrency, antiBotHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, sqrtPriceX96);
// Add liquidity around the current price.
int24 tickLower = targetTick - 60;
int24 tickUpper = targetTick + 60;
uint256 amount0Desired = 10 ether;
uint256 amount1Desired = 100_000 * 1e6;
uint128 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96,
TickMath.getSqrtPriceAtTick(tickLower),
TickMath.getSqrtPriceAtTick(tickUpper),
amount0Desired,
amount1Desired
);
modifyLiquidityRouter.modifyLiquidity{value: amount0Desired}(
key,
ModifyLiquidityParams({
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: int256(uint256(liquidityDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
// Compare liquidity-based limits to token-based limits from pool state.
uint256 initialLiquidity = uint256(StateLibrary.getLiquidity(manager, key.toId()));
uint256 maxSwapAmount = (initialLiquidity * PHASE1_LIMIT_BPS) / 10_000;
ModifyLiquidityParams memory rangeParams = ModifyLiquidityParams({
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: int256(uint256(liquidityDelta)),
salt: bytes32(0)
});
(uint256 amount0InPool, uint256 amount1InPool) = AmountHelpers.getMaxAmountInForPool(manager, rangeParams, key);
uint256 token1Limit = (amount1InPool * PHASE1_LIMIT_BPS) / 10_000;
uint256 largerLimit = maxSwapAmount > token1Limit ? maxSwapAmount : token1Limit;
uint256 smallerLimit = maxSwapAmount > token1Limit ? token1Limit : maxSwapAmount;
assertGt(amount0InPool, 0, "Token0 amount should be non-zero");
assertGt(amount1InPool, 0, "Token1 amount should be non-zero");
assertGt(largerLimit, smallerLimit * 3, "Liquidity-based limit should diverge materially from token1 limit");
}
}

Test Result

forge test --match-contract Finding4_LiquidityMismatchReal -vv
Ran 1 test for test/Finding4_LiquidityMismatchReal.t.sol:Finding4_LiquidityMismatchReal
[PASS] test_LiquidityLimitMismatch_ForImbalancedPrice() (gas: 3105435)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 31.51ms (28.91ms CPU time)

Recommended Mitigation

Convert liquidity to token amounts at the current price and compare against the relevant token-side amount, or store limits in explicit token units.

- uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
- if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
+ (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(poolManager, key.toId());
+ uint256 tokenLimit = calculateTokenLimitFromLiquidity(initialLiquidity, sqrtPriceX96, phaseLimitBps, params.zeroForOne);
+ if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > tokenLimit) {
applyPenalty = true;
}

Support

FAQs

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

Give us feedback!