Vanguard

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

Zero fee override for legitimate swaps

Author Revealed upon completion

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));
}
// @> BUG: even when no penalty applies, the override flag is returned with 0 => forces 0% fee
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

  • Create ZeroFeeOverrideOnLegitSwaps.t.sol under test directory and copy code below.

  • Run command forge test --mt test_ZeroFeeOverride_ForCompliantSwap_OverridesDynamicFee -vvvv.

// SPDX-License-Identifier: MIT
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; // 1%
uint256 phase2LimitBps = 300; // 3%
uint256 phase1Cooldown = 5;
uint256 phase2Cooldown = 3;
uint256 phase1PenaltyBps = 500; // 5%
uint256 phase2PenaltyBps = 200; // 2%
function setUp() public {
deployFreshManagerAndRouters();
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 1_000 ether);
// Mine correct flags for this hook: AFTER_INITIALIZE + BEFORE_SWAP
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);
// Initialize the dynamic-fee pool (fee starts at 0 by default for dynamic pools) [3](https://docs.uniswap.org/contracts/v4/concepts/dynamic-fees)
(key,) = initPool(ethCurrency, tokenCurrency, hook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1);
// Add liquidity so we can swap
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
);
// Set a non-zero dynamic LP fee (0.30% = 3,000 pips) as the HOOK (authorized) [2](https://deepwiki.com/Uniswap/v4-core/4.2-lp-fees)
vm.prank(address(hook));
manager.updateDynamicLPFee(key, uint24(3_000));
}
function test_ZeroFeeOverride_ForCompliantSwap_OverridesDynamicFee() public {
// 1) What the buggy hook returns for a compliant swap:
// OVERRIDE_FEE_FLAG with value 0 pips => instructs 0% LP fee for this swap. [1](https://github.com/Uniswap/v4-periphery/issues/114)
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");
// 2) Execute a small compliant swap to exercise beforeSwap path
uint256 swapAmount = 0.001 ether;
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(swapAmount), // exact-input style path used in your suite
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);
// Explanation:
// - The pool now has a non-zero dynamic fee (0.3%) configured.
// - For compliant swaps, the current hook logic returns 0 | OVERRIDE_FEE_FLAG,
// forcing a 0% LP fee and ignoring the pool’s configured dynamic fee for that swap. [1](https://github.com/Uniswap/v4-periphery/issues/114)
}
}

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

  • Set the override flag when a penalty should apply; otherwise, return 0 (no override), allowing the PoolManager to use the pool’s configured fee.

- 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);

Support

FAQs

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

Give us feedback!