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 {
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
);
(PoolKey memory freshKey,) =
initPool(ethCurrency, tokenCurrency, freshHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1_s);
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
);
uint128 liquidity = StateLibrary.getLiquidity(manager, freshKey.toId());
uint256 maxSwapAmount = (uint256(liquidity) * phase1LimitBps) / 10000;
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().