Root + Impact
- In "Phase 3" (e.g. no active launch phase) in the `_beforeSwap` hook implementation sets the fee override to zero, effectively waiving all swap fees instead of applying the standard Uniswap fee.
- As a result, under normal post-launch trading conditions, users would pay no fees on swaps, leading to zero fee revenue for liquidity providers in the pool.
Description
-
In `_beforeSwap()`, there is a series of checks, including a check for which phase we are in. If we are in Phase 3, the function immediately returns with "return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, LPFeeLibrary.OVERRIDE_FEE_FLAG);"
-
The override fee flag should be accompanied with a fee, such as "fee | LPFeeLibrary.OVERRIDE_FEE_FLAG", so the above effectively passes " 0 | OVERRIDE_FEE_FLAG", which results in a fee of zero and zero revenue for LPs
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
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);
}
}
Risk
Likelihood: High
Impact: Medium
Proof of Concept
function test_NoFeesInPhase3() public {
vm.deal(user1, 1 ether);
uint256 amountIn = 0.001 ether;
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(amountIn),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
uint256 initialUser1TokenBalance = token.balanceOf(user1);
vm.roll(block.number + phase1Duration + phase2Duration + 1);
assertEq(antiBotHook.getCurrentPhase(), 3, "Should be in phase 3");
vm.startPrank(user1);
token.approve(address(swapRouter), type(uint256).max);
swapRouter.swap{value: amountIn}(key, params, testSettings, abi.encode(user1));
vm.stopPrank();
uint256 FinalUser1TokenBalance = token.balanceOf(user1);
uint256 tolerance = 1e10;
assertApproxEqAbs(FinalUser1TokenBalance - initialUser1TokenBalance, amountIn, tolerance);
}
Recommended Mitigation
+ uint256 public immutable phase3StandardFeeBps;
constructor(
IPoolManager _poolManager,
uint256 _phase1Duration,
uint256 _phase2Duration,
uint256 _phase1LimitBps,
uint256 _phase2LimitBps,
uint256 _phase1Cooldown,
uint256 _phase2Cooldown,
uint256 _phase1PenaltyBps,
- uint256 _phase2PenaltyBps
+ uint256 _phase2PenaltyBps,
+ uint256 _phase3StandardFeeBps
) BaseHook(_poolManager) {
if (_phase1Duration == 0 || _phase2Duration == 0) revert InvalidConstructorParams();
if (
- _phase1LimitBps > 10000 || _phase2LimitBps > 10000 || _phase1PenaltyBps > 10000 || _phase2PenaltyBps > 10000
+ _phase1LimitBps > 10000 || _phase2LimitBps > 10000 || _phase1PenaltyBps > 10000 || _phase2PenaltyBps > 10000 || _phase3StandardFeeBps > 10000
) revert InvalidConstructorParams();
// ...
+ phase3StandardFeeBps = _phase3StandardFeeBps;
}
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
// ...
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);
+ uint24 standardFee = uint24(phase3StandardFeeBps * 100); // Convert bps to Uniswap fee
+ return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, standardFee | LPFeeLibrary.OVERRIDE_FEE_FLAG);
}
// ... rest of function
}