Vanguard

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

Zero fees after launch due to always returning OVERRIDE_FEE_FLAG

Author Revealed upon completion

Root + Impact

Root: _beforeSwap always returns OVERRIDE_FEE_FLAG even when there is no penalty, and in phase 3 it returns a 0 fee even though the dynamic fee can be non-zero.
Impact: The default LP fee is overridden and users can swap with zero fees in phase 3 (and in other phases when no penalty applies), depriving LPs of expected fees and contradicting “Standard Uniswap fees”.

Description

Normal behavior: When no penalty applies, the pool’s default LP fee should be used, and in phase 3 it should revert to standard fees.
Issue: The code always returns OVERRIDE_FEE_FLAG and sets the fee to 0, resulting in zero fees even after launch.

if (currentPhase == 3) {
@> return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, LPFeeLibrary.OVERRIDE_FEE_FLAG);
}
...
@> return (
@> BaseHook.beforeSwap.selector,
@> BeforeSwapDeltaLibrary.ZERO_DELTA,
@> feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
@> );

Risk

Likelihood:

  • Occurs on every swap in phase 3 or when no penalty applies in phases 1 and 2.

Impact:
Reduced or zero trading fees for LPs and incentivized fee-free swaps after launch.

vulnerability Path

Steps:

  1. Set a non-zero dynamic LP fee for the pool.

  2. Move to phase 3.

  3. Execute a normal swap.

  4. Observe the Swap event records fee = 0 because of OVERRIDE_FEE_FLAG.

Proof of Concept

The test returns a 0 fee in phase 3 even after updating the LP fee to 3000.

// File: test/PoC_Phase3FeeOverrideZero.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {TokenLaunchHook} from "../src/TokenLaunchHook.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 {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {ERC1155TokenReceiver} from "solmate/src/tokens/ERC1155.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";
contract PoCPhase3FeeOverrideZero is Test, Deployers, ERC1155TokenReceiver {
bytes32 private constant SWAP_EVENT_SIG =
keccak256("Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)");
MockERC20 token;
TokenLaunchHook public antiBotHook;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
address user1 = address(0x1);
uint160 constant SQRT_PRICE_1_1_s = 79228162514264337593543950336;
uint256 phase1Duration = 100;
uint256 phase2Duration = 100;
uint256 phase1LimitBps = 100;
uint256 phase2LimitBps = 500;
uint256 phase1Cooldown = 5;
uint256 phase2Cooldown = 2;
uint256 phase1PenaltyBps = 1000;
uint256 phase2PenaltyBps = 500;
function setUp() public {
deployFreshManagerAndRouters();
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 1000 ether);
token.mint(user1, 1000 ether);
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);
antiBotHook = new TokenLaunchHook{salt: salt}(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
require(address(antiBotHook) == hookAddress, "Hook address mismatch");
token.approve(address(swapRouter), type(uint256).max);
token.approve(address(modifyLiquidityRouter), type(uint256).max);
vm.prank(user1);
token.approve(address(swapRouter), type(uint256).max);
(key,) = initPool(ethCurrency, tokenCurrency, antiBotHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1_s);
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
);
}
function test_PoC_Phase3FeeOverrideZeroesDynamicFee() public {
// Move to phase 3
vm.roll(block.number + phase1Duration + phase2Duration + 1);
// Update the dynamic LP fee to a non-zero value
vm.prank(address(antiBotHook));
manager.updateDynamicLPFee(key, 3000);
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});
// Execute a swap in phase 3 and capture the event to verify the fee value
vm.recordLogs();
swapRouter.swap{value: 0.001 ether}(key, params, testSettings, ZERO_BYTES);
Vm.Log[] memory logs = vm.getRecordedLogs();
uint24 fee = _getLastSwapFee(logs, PoolId.unwrap(key.toId()), address(swapRouter));
vm.stopPrank();
// Assert the fee is zero due to OVERRIDE_FEE_FLAG
assertEq(fee, 0, "Fee override forces zero fee in phase 3");
}
function _getLastSwapFee(Vm.Log[] memory logs, bytes32 poolId, address sender) internal pure returns (uint24) {
bytes32 senderTopic = bytes32(uint256(uint160(sender)));
uint24 fee = 0;
bool found = false;
for (uint256 i = 0; i < logs.length; i++) {
Vm.Log memory log = logs[i];
if (log.topics.length == 3 && log.topics[0] == SWAP_EVENT_SIG && log.topics[1] == poolId) {
if (log.topics[2] == senderTopic) {
(, , , , , uint24 parsedFee) = abi.decode(log.data, (int128, int128, uint160, uint128, int24, uint24));
fee = parsedFee;
found = true;
}
}
}
require(found, "Swap log not found");
return fee;
}
}

Test Result

forge test --match-test test_Phase3FeeOverrideZeroesDynamicFee
Ran 1 test for test/PoC_Phase3FeeOverrideZero.t.sol:PoCPhase3FeeOverrideZero
[PASS] test_PoC_Phase3FeeOverrideZeroesDynamicFee() (gas: 167251)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 665.76ms (2.21ms CPU time)
Ran 1 test suite in 667.19ms (665.76ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Return the override flag only when a non-zero penalty fee must be enforced. In phase 3, return 0 to let the pool use its stored dynamic fee. This preserves standard fees while keeping penalty logic intact in phases 1 and 2.

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

Support

FAQs

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

Give us feedback!