Vanguard

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

Non-penalized swaps in `TokenLaunchHook::_beforeSwap` pay zero fees due to unconditional fee override flag

Author Revealed upon completion

Non-penalized swaps in TokenLaunchHook::_beforeSwap pay zero fees due to unconditional fee override flag

Description

In phases 1 and 2, the TokenLaunchHook::_beforeSwap function always returns the OVERRIDE_FEE_FLAG, with a feeOverride value regardless of whether a penalty is being applied:

function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity);
}
uint256 blocksSinceLaunch = block.number - launchStartBlock;
uint256 newPhase;
if (blocksSinceLaunch <= phase1Duration) {
newPhase = 1;
} else if (blocksSinceLaunch <= phase1Duration + phase2Duration) {
newPhase = 2;
} else {
newPhase = 3;
}
if (newPhase != currentPhase) {
_resetPerAddressTracking();
currentPhase = newPhase;
lastPhaseUpdateBlock = block.number;
}
if (currentPhase == 3) {
@> return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, LPFeeLibrary.OVERRIDE_FEE_FLAG);
@> // Phase 3 also has this issue - returns OVERRIDE_FEE_FLAG with 0 fee
}
uint256 phaseLimitBps = currentPhase == 1 ? phase1LimitBps : phase2LimitBps;
uint256 phaseCooldown = currentPhase == 1 ? phase1Cooldown : phase2Cooldown;
uint256 phasePenaltyBps = currentPhase == 1 ? phase1PenaltyBps : phase2PenaltyBps;
uint256 swapAmount =
params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);
uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
bool applyPenalty = false;
if (addressLastSwapBlock[sender] > 0) {
uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[sender];
if (blocksSinceLastSwap < phaseCooldown) {
applyPenalty = true;
}
}
if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
addressSwappedAmount[sender] += swapAmount;
addressLastSwapBlock[sender] = block.number;
@> uint24 feeOverride = 0;
if (applyPenalty) {
feeOverride = uint24((phasePenaltyBps * 100));
}
@> // When applyPenalty is false, feeOverride remains 0
@> // But OVERRIDE_FEE_FLAG is ALWAYS included, telling PoolManager to use fee = 0
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
@> feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}

The OVERRIDE_FEE_FLAG (0x400000) tells the PoolManager to use the returned value as the swap fee instead of the pool's configured LP fee. When applyPenalty is false:

  • feeOverride remains 0

  • Return value is 0 | 0x400000 = 0x400000

  • PoolManager sees the override flag and uses fee = 0

This means all non-penalized swaps pay zero fees, completely eliminating LP revenue for legitimate swaps.

From LPFeeLibrary.sol:

/// @notice the second bit of the fee returned by beforeSwap is used to signal
/// if the stored LP fee should be overridden in this swap
uint24 public constant OVERRIDE_FEE_FLAG = 0x400000;

Risk

Likelihood:

  • Every swap that doesn't trigger a penalty is affected

  • This is the default behavior, not an edge case

Impact:

  • LPs earn zero fees on all non-penalized swaps

  • Removes economic incentive to provide liquidity

  • Pool becomes economically unviable for liquidity providers

Proof of Concept

The test bellow clearly shows that when a user executes a swap through the pool, if it is not penalised (doesn't breach swap or cooldown limits), there are zero fees implemented.

NOTE For this test to work TokenLaunchHook::_afterInitialize must be updated to set the pool's default LP fee. This is another bug in the currrent hook implementation, where no fee is ever set for the pool.

function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
launchStartBlock = block.number;
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity);
currentPhase = 1;
lastPhaseUpdateBlock = block.number;
+ // Set the pool's default LP fee (e.g., 0.3% = 3000)
+ poolManager.updateDynamicLPFee(key, 3000);
return BaseHook.afterInitialize.selector;
}

Add this test to TokenLaunchHookUnit.t.sol:

function test_NonPenalizedSwapsPayZeroFee() public {
// This test proves that non-penalized swaps pay 0% fee instead of the pool's LP fee
// because the hook returns OVERRIDE_FEE_FLAG | 0, which overrides the fee to 0%
// The pool's LP fee is set to 3000 (0.3%) in afterInitialize
(,,, uint24 poolLPFee) = StateLibrary.getSlot0(manager, key.toId());
console.log("Pool LP fee (should be 3000):", poolLPFee);
assertEq(poolLPFee, 3000, "Pool LP fee should be 0.3%");
// Small swap that won't trigger penalty
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});
// Execute actual swap and get the delta
BalanceDelta delta = swapRouter.swap{value: swapAmount}(key, params, testSettings, ZERO_BYTES);
// At 1:1 price, with 0% fee, output should equal input
// With 0.3% fee, output should be input * 0.997
uint256 tokensReceived = uint256(int256(delta.amount1()));
uint256 ethSpent = uint256(-int256(delta.amount0()));
console.log("=== Phase 1 Non-Penalized Swap ===");
console.log("ETH spent:", ethSpent);
console.log("Tokens received:", tokensReceived);
// If fee was 0.3%, tokens received should be ~0.997 * ethSpent
// If fee was 0%, tokens received should be ~1.0 * ethSpent
// The ratio tells us what fee was actually charged
uint256 ratio = (tokensReceived * 10000) / ethSpent;
console.log("Ratio (tokens/eth * 10000):", ratio);
// BUG: Ratio is ~10000 (no fee) instead of ~9970 (0.3% fee)
// This proves the hook's OVERRIDE_FEE_FLAG | 0 is overriding to 0% fee
assertGt(ratio, 9990, "Ratio > 9990 means fee is less than 0.1% (should be 0.3%)");
// ========== Now test Phase 3 ==========
vm.roll(block.number + phase1Duration + phase2Duration + 1);
// Use a different user to avoid cumulative tracking
vm.deal(user2, 1 ether);
vm.startPrank(user2);
token.approve(address(swapRouter), type(uint256).max);
BalanceDelta delta3 = swapRouter.swap{value: swapAmount}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 tokensReceived3 = uint256(int256(delta3.amount1()));
uint256 ethSpent3 = uint256(-int256(delta3.amount0()));
uint256 ratio3 = (tokensReceived3 * 10000) / ethSpent3;
console.log("=== Phase 3 Swap ===");
console.log("ETH spent:", ethSpent3);
console.log("Tokens received:", tokensReceived3);
console.log("Ratio (tokens/eth * 10000):", ratio3);
// BUG: Phase 3 also charges 0% fee instead of pool's 0.3% LP fee
// README says "Post-launch: Standard Uniswap fees apply" but they don't
assertGt(ratio3, 9990, "Phase 3: Ratio > 9990 means fee is less than 0.1%");
}

Recommended Mitigation

Two changes are required:

1. Update TokenLaunchHook::_afterInitialize to set the pool's default LP fee:

function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
launchStartBlock = block.number;
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity);
currentPhase = 1;
lastPhaseUpdateBlock = block.number;
+ // Set the pool's default LP fee (e.g., 0.3% = 3000)
+ poolManager.updateDynamicLPFee(key, 3000);
return BaseHook.afterInitialize.selector;
}

2. Update TokenLaunchHook::_beforeSwap to only override fee when applying a penalty:

function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity);
}
uint256 blocksSinceLaunch = block.number - launchStartBlock;
uint256 newPhase;
if (blocksSinceLaunch < phase1Duration) {
newPhase = 1;
} else if (blocksSinceLaunch < phase1Duration + phase2Duration) {
newPhase = 2;
} else {
newPhase = 3;
}
if (newPhase != currentPhase) {
_resetPerAddressTracking();
currentPhase = newPhase;
lastPhaseUpdateBlock = block.number;
}
if (currentPhase == 3) {
- return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, LPFeeLibrary.OVERRIDE_FEE_FLAG);
+ // Phase 3: no override, use pool's default LP fee
+ return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
}
uint256 phaseLimitBps = currentPhase == 1 ? phase1LimitBps : phase2LimitBps;
uint256 phaseCooldown = currentPhase == 1 ? phase1Cooldown : phase2Cooldown;
uint256 phasePenaltyBps = currentPhase == 1 ? phase1PenaltyBps : phase2PenaltyBps;
uint256 swapAmount =
params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified);
uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
bool applyPenalty = false;
if (addressLastSwapBlock[sender] > 0) {
uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[sender];
if (blocksSinceLastSwap < phaseCooldown) {
applyPenalty = true;
}
}
if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
addressSwappedAmount[sender] += swapAmount;
addressLastSwapBlock[sender] = block.number;
- uint24 feeOverride = 0;
if (applyPenalty) {
- feeOverride = uint24((phasePenaltyBps * 100));
+ // Override with penalty fee
+ uint24 feeOverride = uint24((phasePenaltyBps * 100));
+ return (
+ BaseHook.beforeSwap.selector,
+ BeforeSwapDeltaLibrary.ZERO_DELTA,
+ feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
+ );
}
+ // No penalty - don't override, use pool's default LP fee
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
- feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
+ 0
);
}

Support

FAQs

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

Give us feedback!