Vanguard

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

maxSwapAmount calculation uses V4 liquidity units instead of token amounts, rendering swap limits ineffective

Author Revealed upon completion

maxSwapAmount calculation uses V4 liquidity units instead of token amounts, rendering swap limits ineffective

Description

The _beforeSwap function calculates maxSwapAmount using initialLiquidity, which is a Uniswap V4 liquidity unit, not a token amount:

uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;

In Uniswap V4 (and V3), the liquidity value is a mathematical unit derived from the concentrated liquidity formula - roughly sqrt(amount0 * amount1) scaled by tick range. For concentrated positions (narrow tick ranges), this value is orders of magnitude larger than the actual token amounts deposited.

This means a phase1LimitBps of 100 (intended to be 1% of the pool) actually calculates to a maxSwapAmount that can exceed the entire pool's reserves, making the anti-bot swap limits completely ineffective.

Risk

Likelihood:

This affects every pool using this hook. The liquidity-to-token ratio depends on the tick range used when adding liquidity, but for typical concentrated positions the calculated limit will be significantly higher than intended.

Impact:

The calcualtion for maxSwapAmount and the derived penalty fees is broken. Users can swap far larger amounts than intended before hitting penalty limits.

Example with real numbers:

  • Pool: 100 ETH + 100 tokens deposited in tick range -60 to 60

  • initialLiquidity: ~33.385e21 (V4 liquidity units)

  • phase1LimitBps: 100 (intended 1% limit)

  • Calculated maxSwapAmount: ~333e18 tokens (333 tokens)

  • Actual pool reserves: 100 tokens

  • Result: The "1% limit" allows 333% of the pool's value - effectively no limit at all

Proof of Concept

Add the following test to TokenLaunchHookUnit.t.sol: This test clearly shows that the calcualted maxSwapAmount is greater than the total amount of native ETH liqudity added to the pool, which is incorrect.

function test_LiquidityCalculationIsIncorrect() public {
// Deploy a fresh hook for this test
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory constructorArgs = abi.encode(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
uint160 flags = uint160(Hooks.AFTER_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG);
(address hookAddress, bytes32 salt) = HookMiner.find(address(this), flags, creationCode, constructorArgs);
TokenLaunchHook freshHook = new TokenLaunchHook{salt: salt}(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
// Initialize pool - initialLiquidity is 0 at this point
(PoolKey memory freshKey,) =
initPool(ethCurrency, tokenCurrency, freshHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1_s);
// Add liquidity to the pool
uint256 liquidityToAdd = 100 ether;
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint128 liquidityDelta =
LiquidityAmounts.getLiquidityForAmount0(SQRT_PRICE_1_1, sqrtPriceAtTickUpper, liquidityToAdd);
modifyLiquidityRouter.modifyLiquidity{value: liquidityToAdd}(
freshKey,
ModifyLiquidityParams({
tickLower: -60, tickUpper: 60, liquidityDelta: int256(uint256(liquidityDelta)), salt: bytes32(0)
}),
ZERO_BYTES
);
// Get the calculated max swap amount
uint128 liquidity = StateLibrary.getLiquidity(manager, freshKey.toId());
uint256 maxSwapAmount = (uint256(liquidity) * phase1LimitBps) / 10000;
// Calcualted max swap amount is greater than the total native ETH liquidity added, showing that the calculation is incorrect
assertGt(maxSwapAmount, liquidityToAdd, "Max swap amount is greater than the total liquidity added");
console.log("Liquidity to add:", liquidityToAdd);
console.log("Calculated maxSwapAmount:", maxSwapAmount);
}

Recommended Mitigation

Track actual token amounts from liquidity additions instead of using the abstract liquidity value. The afterAddLiquidity hook receives a BalanceDelta containing the real token amounts:

+ mapping(PoolKey => uint256) initialAmounts0;
+ mapping(PoolKey => uint256) initialAmounts1;
+ function _afterAddLiquidity(
+ address,
+ PoolKey calldata key,
+ ModifyLiquidityParams calldata,
+ BalanceDelta delta,
+ BalanceDelta,
+ bytes calldata
+ ) internal override returns (bytes4, BalanceDelta) {
+ if (initialAmounts0[key] == 0 && initialAmount1 == 0) {
+ // Capture actual token amounts from first liquidity addition
+ // delta amounts are negative for tokens going INTO the pool
+ initialAmounts0[key] = delta.amount0() < 0 ? uint256(-int256(delta.amount0())) : 0;
+ initialAmounts1[key] = delta.amount1() < 0 ? uint256(-int256(delta.amount1())) : 0;
+ }
+ return (this.afterAddLiquidity.selector, BalanceDeltaLibrary.ZERO_DELTA);
+ }
function _beforeSwap(...) internal override returns (bytes4, BeforeSwapDelta, uint24) {
// ... existing code ...
- uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
+ // Use the appropriate token amount based on swap direction
+ // zeroForOne: true = selling token0, false = selling token1
+ uint256 baseAmount = params.zeroForOne ? initialAmounts0[key] : initialAmounts1[key];
+ uint256 maxSwapAmount = (baseAmount * phaseLimitBps) / 10000;
// ... rest of function ...
}

Note: You will also need to add AFTER_ADD_LIQUIDITY_FLAG to the hook's permissions in getHookPermissions().

Support

FAQs

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

Give us feedback!