Vanguard

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

Zero LP Fee Override Causes Complete Loss of Fee Revenue Across All Launch Phases

Author Revealed upon completion

Root + Impact

Description

  • In a Uniswap V4 pool with dynamic fees enabled, hooks can either override the swap fee to a specific value by setting the OVERRIDE_FEE_FLAG along with fee bits, or return no override flag to allow the pool's default fee to apply. The TokenLaunchHook is designed to apply penalty fees during Phases 1 and 2 when users violate limits or cooldowns, and should allow standard pool fees to apply for compliant swaps and all Phase 3 (post-launch) swaps, as stated in the README: "Post-launch: Standard Uniswap fees apply."

  • The hook incorrectly returns OVERRIDE_FEE_FLAG with zero fee bits for all compliant swaps in Phases 1/2 and all swaps in Phase 3, which overrides the pool fee to 0% instead of allowing the default fee to apply. This occurs because the code sets feeOverride = 0 when no penalty is needed, then ORs it with OVERRIDE_FEE_FLAG (lines 174-182), and Phase 3 explicitly returns only OVERRIDE_FEE_FLAG without fee bits (lines 151-153). As a result, LPs receive zero fee revenue on the vast majority of swaps, contradicting the documented behavior and breaking the pool's economic model.

Location 1:

https://github.com/CodeHawks-Contests/2026-01-vanguard/blob/9fa43cd0950d6baf301cada9b40d31c28b65bbe8/src/TokenLaunchHook.sol#L125

function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
// ... phase calculation logic ...
if (currentPhase == 3) {
@> return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, LPFeeLibrary.OVERRIDE_FEE_FLAG);
}

https://github.com/CodeHawks-Contests/2026-01-vanguard/blob/9fa43cd0950d6baf301cada9b40d31c28b65bbe8/src/TokenLaunchHook.sol#L152

Issue: Returns only OVERRIDE_FEE_FLAG without any fee bits set, encoding a 0% fee override instead of returning 0 (no override) to use the pool's default fee.


Location 2:

https://github.com/CodeHawks-Contests/2026-01-vanguard/blob/9fa43cd0950d6baf301cada9b40d31c28b65bbe8/src/TokenLaunchHook.sol#L174C7-L183C6

@> uint24 feeOverride = 0;
if (applyPenalty) {
feeOverride = uint24((phasePenaltyBps * 100));
}
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
@> feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG // When applyPenalty=false: 0 | FLAG = 0% fee
);

Issue: When applyPenalty = false, feeOverride remains 0, and 0 | OVERRIDE_FEE_FLAG encodes "override to 0% fee" rather than "use pool default fee."​ The code should return 0 (no flag) when no override is needed, but instead always sets the override flag regardless of whether a penalty applies.


Risk

Likelihood:

  • This vulnerability triggers on every single swap that does not violate the hook's limits or cooldowns, which represents the majority of legitimate trading activity. During Phase 1 and Phase 2, any user who stays within the maxSwapAmount limit and respects the cooldown period will execute a compliant swap that receives the 0% fee override.

  • The vulnerability becomes permanent and affects 100% of swaps once the pool enters Phase 3 (post-launch), which occurs automatically after phase1Duration + phase2Duration blocks have elapsed since launchStartBlock. At this point, all swaps unconditionally return OVERRIDE_FEE_FLAG with zero fee bits, making the pool a permanent zero-fee trading venue

Impact:

  • Liquidity providers lose 100% of their expected fee revenue on all compliant swaps during Phases 1/2 and all swaps during Phase 3, permanently breaking the pool's economic incentive model. This makes providing liquidity economically irrational, as LPs earn zero fees while still bearing impermanent loss and capital opportunity cost.

  • Arbitrageurs and MEV searchers gain a permanent zero-cost trading advantage, allowing them to extract value through risk-free arbitrage while contributing no fees to the pool. This exacerbates LP losses by enabling traders to profit from price discrepancies without compensating liquidity providers, contradicting the README's documented behavior that "Post-launch: Standard Uniswap fees apply.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";
import {TokenLaunchHook} from "../src/TokenLaunchHook.sol";
import {TokenLaunchHookHarness} from "./TokenLaunchHookHarness.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 {PoolManager} from "v4-core/PoolManager.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol";
import {ERC1155TokenReceiver} from "solmate/src/tokens/ERC1155.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {PoolId} from "v4-core/types/PoolId.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
import {BaseHook} from "v4-periphery/src/utils/BaseHook.sol";
/// @title Zero Fee Override Bug PoC
/// @notice Demonstrates that the TokenLaunchHook incorrectly overrides LP fees to 0%
/// in phase 3 and for compliant swaps in phases 1/2, causing LPs to lose all fee revenue
contract ZeroFeeOverrideBugTest is Test, Deployers, ERC1155TokenReceiver {
MockERC20 token;
TokenLaunchHookHarness public antiBotHook;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
address user1 = address(0x1);
uint256 phase1Duration = 100;
uint256 phase2Duration = 100;
uint256 phase1LimitBps = 100; // 1%
uint256 phase2LimitBps = 500; // 5%
uint256 phase1Cooldown = 5;
uint256 phase2Cooldown = 2;
uint256 phase1PenaltyBps = 1000; // 10%
uint256 phase2PenaltyBps = 500; // 5%
function setUp() public {
// Deploy Uniswap V4 infrastructure
deployFreshManagerAndRouters();
// Create and mint test token
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 1000 ether);
token.mint(user1, 1000 ether);
// Deploy the harness hook with proper address
bytes memory creationCode = type(TokenLaunchHookHarness).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);
antiBotHook = new TokenLaunchHookHarness{salt: salt}(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
require(address(antiBotHook) == hookAddress, "Hook address mismatch");
// Approve tokens
token.approve(address(swapRouter), type(uint256).max);
token.approve(address(modifyLiquidityRouter), type(uint256).max);
vm.prank(user1);
token.approve(address(swapRouter), type(uint256).max);
// Initialize pool
(key,) = initPool(ethCurrency, tokenCurrency, antiBotHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1);
// Add liquidity
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint256 ethToAdd = 10 ether;
uint128 liquidityDelta = LiquidityAmounts.getLiquidityForAmount0(SQRT_PRICE_1_1, sqrtPriceAtTickUpper, ethToAdd);
modifyLiquidityRouter.modifyLiquidity{value: ethToAdd}(
key,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(liquidityDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
}
/* ══════════════════════════════════════════════════════════════════
ZERO FEE OVERRIDE BUG PoCs
══════════════════════════════════════════════════════════════════ */
/// @notice In phase 3, hook should allow standard Uniswap fees,
/// but it instead overrides to 0% fee (OVERRIDE_FEE_FLAG with zero fee bits).
function test_ZeroFeeOverride_Phase3_AllSwapsFree() public {
console.log("\n=== Testing Phase 3 Zero Fee Override Bug ===");
// Move blocks so next swap will transition to phase 3
vm.roll(block.number + phase1Duration + phase2Duration + 1);
vm.deal(user1, 1 ether);
vm.startPrank(user1);
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(0.001 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
// Perform a regular swap via the router to trigger the hook
swapRouter.swap{value: 0.001 ether}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
// Sanity: phase is now 3
assertEq(antiBotHook.getCurrentPhase(), 3, "Should be in phase 3");
assertEq(antiBotHook.currentPhase(), 3, "Stored currentPhase should be 3");
console.log("Current phase:", antiBotHook.getCurrentPhase());
// Directly call the hook to read the feeOverride it returns in phase 3
(bytes4 sel, , uint24 feeOverride) =
antiBotHook.exposed_beforeSwap(address(swapRouter), key, params, ZERO_BYTES);
// Hook must return beforeSwap selector
assertEq(sel, BaseHook.beforeSwap.selector, "Unexpected selector");
console.log("Fee override returned:", feeOverride);
console.log("OVERRIDE_FEE_FLAG:", LPFeeLibrary.OVERRIDE_FEE_FLAG);
// BUG: feeOverride equals OVERRIDE_FEE_FLAG with zero fee bits => 0% fee override
assertEq(
feeOverride,
LPFeeLibrary.OVERRIDE_FEE_FLAG,
"Phase 3 should NOT be overriding to 0% fee"
);
// Extract encoded fee bits (lower 20 bits)
uint24 encodedFee = feeOverride & 0x000FFFFF;
console.log("Encoded fee (bits 0-19):", encodedFee);
assertEq(encodedFee, 0, "Encoded LP fee in phase 3 is 0%, not standard pool fee");
console.log("\n[VULNERABILITY CONFIRMED]");
console.log("Phase 3 swaps pay 0% LP fees instead of standard pool fees");
console.log("LPs lose 100% of fee revenue on all post-launch swaps");
}
/// @notice In phase 1/2, compliant swaps (no penalty) should use standard dynamic fees,
/// but they are also overridden to 0% fee (OVERRIDE_FEE_FLAG with zero fee bits).
function test_ZeroFeeOverride_Phase1_CompliantSwap() public {
console.log("\n=== Testing Phase 1 Compliant Swap Zero Fee Override Bug ===");
// We are in phase 1 immediately after initialization
assertEq(antiBotHook.getCurrentPhase(), 1, "Should start in phase 1");
console.log("Current phase:", antiBotHook.getCurrentPhase());
vm.deal(user1, 1 ether);
vm.startPrank(user1);
uint256 swapAmount = 0.001 ether;
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(swapAmount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
// Single small swap within per-block limit and cooldown -> no penalty should apply
swapRouter.swap{value: swapAmount}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
// Confirm this user (router as sender) is tracked, meaning swap path ran
assertGt(
antiBotHook.addressSwappedAmount(address(swapRouter)),
0,
"SwapRouter should have recorded swapped amount"
);
console.log("Swapped amount recorded:", antiBotHook.addressSwappedAmount(address(swapRouter)));
// Advance past cooldown so next swap is compliant (no cooldown violation)
vm.roll(block.number + phase1Cooldown + 1);
// Now call hook directly with same sender to inspect feeOverride for a compliant swap
(bytes4 sel, , uint24 feeOverride) =
antiBotHook.exposed_beforeSwap(address(swapRouter), key, params, ZERO_BYTES);
assertEq(sel, BaseHook.beforeSwap.selector, "Unexpected selector");
console.log("Fee override returned:", feeOverride);
console.log("OVERRIDE_FEE_FLAG:", LPFeeLibrary.OVERRIDE_FEE_FLAG);
// BUG: for compliant swaps, feeOverride is 0 and only OVERRIDE_FEE_FLAG is set => 0% LP fee
assertEq(
feeOverride,
LPFeeLibrary.OVERRIDE_FEE_FLAG,
"Compliant phase 1 swap should not be forced to 0% LP fee"
);
uint24 encodedFee = feeOverride & 0x000FFFFF;
console.log("Encoded fee (bits 0-19):", encodedFee);
assertEq(encodedFee, 0, "Encoded LP fee for compliant phase 1 swap is 0%, not pool fee");
console.log("\n[VULNERABILITY CONFIRMED]");
console.log("Phase 1 compliant swaps pay 0% LP fees instead of pool fees");
console.log("LPs lose 100% of fee revenue on all non-penalized swaps");
}
/// @notice In phase 2, compliant swaps should also use standard dynamic fees,
/// but are overridden to 0% as well.
function test_ZeroFeeOverride_Phase2_CompliantSwap() public {
console.log("\n=== Testing Phase 2 Compliant Swap Zero Fee Override Bug ===");
// Advance into phase 2 (but not yet phase 3)
vm.roll(block.number + phase1Duration + 1);
assertEq(antiBotHook.getCurrentPhase(), 2, "Should be in phase 2");
console.log("Current phase:", antiBotHook.getCurrentPhase());
vm.deal(user1, 1 ether);
vm.startPrank(user1);
uint256 swapAmount = 0.001 ether;
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(swapAmount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
// Single small swap within limit and cooldown -> no penalty should apply
swapRouter.swap{value: swapAmount}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
// Advance past cooldown so next swap is compliant (no cooldown violation)
vm.roll(block.number + phase2Cooldown + 1);
// Again inspect feeOverride via direct hook call
(bytes4 sel, , uint24 feeOverride) =
antiBotHook.exposed_beforeSwap(address(swapRouter), key, params, ZERO_BYTES);
assertEq(sel, BaseHook.beforeSwap.selector, "Unexpected selector");
console.log("Fee override returned:", feeOverride);
console.log("OVERRIDE_FEE_FLAG:", LPFeeLibrary.OVERRIDE_FEE_FLAG);
// BUG: compliant phase-2 swap also gets OVERRIDE_FEE_FLAG with zero fee bits => 0% fee
assertEq(
feeOverride,
LPFeeLibrary.OVERRIDE_FEE_FLAG,
"Compliant phase 2 swap should not be forced to 0% LP fee"
);
uint24 encodedFee = feeOverride & 0x000FFFFF;
console.log("Encoded fee (bits 0-19):", encodedFee);
assertEq(encodedFee, 0, "Encoded LP fee for compliant phase 2 swap is 0%, not pool fee");
console.log("\n[VULNERABILITY CONFIRMED]");
console.log("Phase 2 compliant swaps pay 0% LP fees instead of pool fees");
console.log("LPs lose 100% of fee revenue on all non-penalized swaps");
}
}

POC RESULT:

forge test --match-path test/ZeroFeeOverrideBugTest.sol -vv
[⠊] Compiling...
[⠊] Compiling 1 files with Solc 0.8.26
[⠢] Solc 0.8.26 finished in 14.98s
Compiler run successful!
Ran 3 tests for test/ZeroFeeOverrideBugTest.sol:ZeroFeeOverrideBugTest
[PASS] test_ZeroFeeOverride_Phase1_CompliantSwap() (gas: 208693)
Logs:
=== Testing Phase 1 Compliant Swap Zero Fee Override Bug ===
Current phase: 1
Swapped amount recorded: 1000000000000000
Fee override returned: 4194304
OVERRIDE_FEE_FLAG: 4194304
Encoded fee (bits 0-19): 0
[VULNERABILITY CONFIRMED]
Phase 1 compliant swaps pay 0% LP fees instead of pool fees
LPs lose 100% of fee revenue on all non-penalized swaps
[PASS] test_ZeroFeeOverride_Phase2_CompliantSwap() (gas: 221282)
Logs:
=== Testing Phase 2 Compliant Swap Zero Fee Override Bug ===
Current phase: 2
Fee override returned: 4194304
OVERRIDE_FEE_FLAG: 4194304
Encoded fee (bits 0-19): 0
[VULNERABILITY CONFIRMED]
Phase 2 compliant swaps pay 0% LP fees instead of pool fees
LPs lose 100% of fee revenue on all non-penalized swaps
[PASS] test_ZeroFeeOverride_Phase3_AllSwapsFree() (gas: 174602)
Logs:
=== Testing Phase 3 Zero Fee Override Bug ===
Current phase: 3
Fee override returned: 4194304
OVERRIDE_FEE_FLAG: 4194304
Encoded fee (bits 0-19): 0
[VULNERABILITY CONFIRMED]
Phase 3 swaps pay 0% LP fees instead of standard pool fees
LPs lose 100% of fee revenue on all post-launch swaps
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 210.36ms (747.54µs CPU time)
Ran 1 test suite in 213.66ms (210.36ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation

Fix 1: Phase 3 - Remove Override Flag to Use Pool Default Fee

if (currentPhase == 3) {
- return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, LPFeeLibrary.OVERRIDE_FEE_FLAG);
+ return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
}

Fix 2: Phase 1/2 - Only Set Override Flag When Penalty Applies


- uint24 feeOverride = 0;
+ uint24 feeToReturn = 0;
if (applyPenalty) {
- feeOverride = uint24((phasePenaltyBps * 100));
+ feeToReturn = uint24((phasePenaltyBps * 100)) | LPFeeLibrary.OVERRIDE_FEE_FLAG;
}
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
- feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
+ feeToReturn
);
  • Explanation: The mitigation removes the override flag entirely when no penalty should apply, allowing the pool's configured default fee to be charged. The override flag is now only set when applyPenalty = true, ensuring penalty fees are enforced while compliant swaps use standard pool fees.


Support

FAQs

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

Give us feedback!