Description
-
In Uniswap v4 dynamic‑fee pools, a hook may override the pool’s stored LP fee for a specific swap by returning a fee value with LPFeeLibrary.OVERRIDE_FEE_FLAG set from beforeSwap. If the hook does not set the override flag, the PoolManager applies the pool’s configured fee (static or current dynamic fee). The fee unit is pips (hundredths of a bip), with MAX_LP_FEE = 1_000_000 (100%).
-
In TokenLaunchHook._beforeSwap, when applyPenalty is false (i.e., compliant/legitimate swaps), the code still returns feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG with feeOverride == 0. This forces a 0% LP fee for those swaps, overriding whatever fee the pool was configured to charge. Consequently, LPs receive no fees on legitimate volume, undermining pool economics.
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
...
uint24 feeOverride = 0;
if (applyPenalty) {
feeOverride = uint24((phasePenaltyBps * 100));
}
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}
Risk
Likelihood: High
-
Occurs on every compliant swap that satisfies cooldown/limit checks (the common case during phases 1–2, and any time penalties aren’t triggered).
-
LPFeeLibrary explicitly interprets a value with OVERRIDE_FEE_FLAG set as an instruction to override the pool fee; with the underlying value 0, the applied LP fee becomes 0 pips for that swap.
Impact: High
-
LPs earn zero fees for legitimate trading volume (economic incentive broken); dynamic fees configured on the pool are ignored whenever the hook returns the override flag with a zero fee.
-
Market quality degradation: without LP compensation, liquidity can withdraw, slippage increases, and launch protection goals are undermined.
Proof of Concept
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {TokenLaunchHook} from "../src/TokenLaunchHook.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol";
import {ERC1155TokenReceiver} from "solmate/src/tokens/ERC1155.sol";
contract ZeroFeeOverrideOnLegitSwapsTest is Test, Deployers, ERC1155TokenReceiver {
using LPFeeLibrary for uint24;
TokenLaunchHook hook;
MockERC20 token;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
uint256 phase1Duration = 100;
uint256 phase2Duration = 200;
uint256 phase1LimitBps = 100;
uint256 phase2LimitBps = 300;
uint256 phase1Cooldown = 5;
uint256 phase2Cooldown = 3;
uint256 phase1PenaltyBps = 500;
uint256 phase2PenaltyBps = 200;
function setUp() public {
deployFreshManagerAndRouters();
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 1_000 ether);
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory args = abi.encode(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG);
(address mined, bytes32 salt) =
HookMiner.find(address(this), flags, creationCode, args);
hook = new TokenLaunchHook{salt: salt}(
IPoolManager(manager),
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
require(address(hook) == mined, "Hook address mismatch");
token.approve(address(modifyLiquidityRouter), type(uint256).max);
(key,) = initPool(ethCurrency, tokenCurrency, hook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1);
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint256 ethToAdd = 10 ether;
uint128 liqDelta = LiquidityAmounts.getLiquidityForAmount0(
SQRT_PRICE_1_1,
sqrtPriceAtTickUpper,
ethToAdd
);
modifyLiquidityRouter.modifyLiquidity{value: ethToAdd}(
key,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(liqDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
vm.prank(address(hook));
manager.updateDynamicLPFee(key, uint24(3_000));
}
function test_ZeroFeeOverride_ForCompliantSwap_OverridesDynamicFee() public {
uint24 returned = uint24(0) | LPFeeLibrary.OVERRIDE_FEE_FLAG;
assertTrue(returned.isOverride(), "should be an override fee");
uint24 stripped = returned.removeOverrideFlagAndValidate();
assertEq(stripped, 0, "override value is 0 pips => 0% LP fee");
uint256 swapAmount = 0.001 ether;
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(swapAmount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory settings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
vm.deal(address(this), swapAmount);
swapRouter.swap{value: swapAmount}(key, params, settings, ZERO_BYTES);
}
}
Output:
[⠊] Compiling...
[⠢] Compiling 2 files with Solc 0.8.26
[⠆] Solc 0.8.26 finished in 59.54ms
No files changed, compilation skipped
Ran 1 test for test/ZeroFeeOverrideOnLegitSwaps.t.sol:ZeroFeeOverrideOnLegitSwapsTest
[PASS] test_ZeroFeeOverride_ForCompliantSwap_OverridesDynamicFee() (gas: 181386)
Traces:
[181386] ZeroFeeOverrideOnLegitSwapsTest::test_ZeroFeeOverride_ForCompliantSwap_OverridesDynamicFee()
├─ [0] VM::deal(ZeroFeeOverrideOnLegitSwapsTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1000000000000000 [1e15])
│ └─ ← [Return]
├─ [158624] PoolSwapTest::swap{value: 1000000000000000}(PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x15cF58144EF33af1e14b5208015d11F9143E27b9, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x7E4A8F76FEc89Ed2EbF193e4E5Cc1c1ab32E9080 }), SwapParams({ zeroForOne: true, amountSpecified: -1000000000000000 [-1e15], sqrtPriceLimitX96: 4295128740 [4.295e9] }), TestSettings({ takeClaims: false, settleUsingBurn: false }), 0x)
│ ├─ [152820] PoolManager::unlock(0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e149600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015cf58144ef33af1e14b5208015d11f9143e27b90000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000007e4a8f76fec89ed2ebf193e4e5cc1c1ab32e90800000000000000000000000000000000000000000000000000000000000000001fffffffffffffffffffffffffffffffffffffffffffffffffffc72815b39800000000000000000000000000000000000000000000000000000000001000276a400000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000)
│ │ ├─ [150561] PoolSwapTest::unlockCallback(0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e149600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015cf58144ef33af1e14b5208015d11f9143e27b90000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000007e4a8f76fec89ed2ebf193e4e5cc1c1ab32e90800000000000000000000000000000000000000000000000000000000000000001fffffffffffffffffffffffffffffffffffffffffffffffffffc72815b39800000000000000000000000000000000000000000000000000000000001000276a400000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000)
│ │ │ ├─ [862] PoolManager::exttload(0xc6bd81b73d1fd76b6ab7b2a3e675f602f162633118b2ec0b74d6456669dd8579) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000
│ │ │ ├─ [2552] MockERC20::balanceOf(ZeroFeeOverrideOnLegitSwapsTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall]
│ │ │ │ └─ ← [Return] 990000000000000000000 [9.9e20]
│ │ │ ├─ [2552] MockERC20::balanceOf(PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ │ │ └─ ← [Return] 10000000000000000000 [1e19]
│ │ │ ├─ [862] PoolManager::exttload(0x85be7c2bd5cfd9e6e3a30072d5be012f0c0649c579d3433f4d5ee458bdb429be) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000
│ │ │ ├─ [110236] PoolManager::swap(PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x15cF58144EF33af1e14b5208015d11F9143E27b9, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x7E4A8F76FEc89Ed2EbF193e4E5Cc1c1ab32E9080 }), SwapParams({ zeroForOne: true, amountSpecified: -1000000000000000 [-1e15], sqrtPriceLimitX96: 4295128740 [4.295e9] }), 0x)
│ │ │ │ ├─ [76095] TokenLaunchHook::beforeSwap(PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x15cF58144EF33af1e14b5208015d11F9143E27b9, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x7E4A8F76FEc89Ed2EbF193e4E5Cc1c1ab32E9080 }), SwapParams({ zeroForOne: true, amountSpecified: -1000000000000000 [-1e15], sqrtPriceLimitX96: 4295128740 [4.295e9] }), 0x)
│ │ │ │ │ ├─ [2378] PoolManager::extsload(0xa88b2edf4fb72aaf361d8b4ec9d1adbe5291882f188b44e71deb27a0f6bb7388) [staticcall]
│ │ │ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000b4fb077cf724ed8bd3
│ │ │ │ │ └─ ← [Return] 0x575e24b4, 0, 4194304 [4.194e6]
│ │ │ │ ├─ emit Swap(id: 0xc6eac5486de8d3ffee00cfbcd1e87bfd63f7fbfb9d06fcf16e570fe9fa93a4b5, sender: PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], amount0: -1000000000000000 [-1e15], amount1: 999999700464594 [9.999e14], sqrtPriceX96: 79228138782624522581392935400 [7.922e28], liquidity: 3338502497096994491347 [3.338e21], tick: -1, fee: 0)
│ │ │ │ └─ ← [Return] -340282366920938463463374607431768211455000000299535406 [-3.402e53]
│ │ │ ├─ [862] PoolManager::exttload(0xc6bd81b73d1fd76b6ab7b2a3e675f602f162633118b2ec0b74d6456669dd8579) [staticcall]
│ │ │ │ └─ ← [Return] 0xfffffffffffffffffffffffffffffffffffffffffffffffffffc72815b398000
│ │ │ ├─ [552] MockERC20::balanceOf(ZeroFeeOverrideOnLegitSwapsTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall]
│ │ │ │ └─ ← [Return] 990000000000000000000 [9.9e20]
│ │ │ ├─ [552] MockERC20::balanceOf(PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ │ │ └─ ← [Return] 10000000000000000000 [1e19]
│ │ │ ├─ [862] PoolManager::exttload(0x85be7c2bd5cfd9e6e3a30072d5be012f0c0649c579d3433f4d5ee458bdb429be) [staticcall]
│ │ │ │ └─ ← [Return] 0x00000000000000000000000000000000000000000000000000038d7e92ebf3d2
│ │ │ ├─ [1425] PoolManager::settle{value: 1000000000000000}()
│ │ │ │ └─ ← [Return] 1000000000000000 [1e15]
│ │ │ ├─ [10243] PoolManager::take(MockERC20: [0x15cF58144EF33af1e14b5208015d11F9143E27b9], ZeroFeeOverrideOnLegitSwapsTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 999999700464594 [9.999e14])
│ │ │ │ ├─ [8511] MockERC20::transfer(ZeroFeeOverrideOnLegitSwapsTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 999999700464594 [9.999e14])
│ │ │ │ │ ├─ emit Transfer(from: PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], to: ZeroFeeOverrideOnLegitSwapsTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], amount: 999999700464594 [9.999e14])
│ │ │ │ │ └─ ← [Return] true
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return] 0xfffffffffffffffffffc72815b398000000000000000000000038d7e92ebf3d2
│ │ └─ ← [Return] 0xfffffffffffffffffffc72815b398000000000000000000000038d7e92ebf3d2
│ └─ ← [Return] -340282366920938463463374607431768211455000000299535406 [-3.402e53]
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 27.27ms (503.40µs CPU time)
Ran 1 test suite in 30.02ms (27.27ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
- uint24 feeOverride = 0;
- if (applyPenalty) {
- feeOverride = uint24((phasePenaltyBps * 100));
- }
- return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG);
+ uint24 overrideFee = 0;
+ if (applyPenalty) {
+ overrideFee = uint24(phasePenaltyBps * 100); // BPS -> pips (hundredths of a bip)
+ return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, overrideFee | LPFeeLibrary.OVERRIDE_FEE_FLAG);
+ }
+ // No penalty: do NOT set override flag; let pool's configured fee apply
+ return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);