Vanguard

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

`_beforeSwap` Applies Penalties to All Swaps During Launch Phases Instead of Only Sells

Author Revealed upon completion

Root + Impact

  • The _beforeSwap function in TokenLaunchHook applies penalties based on swap amounts and cooldowns without distinguishing between buy and sell operations

  • This contradicts the stated purpose of the hook, which is to prevent excessive selling, as buy operations are also penalized.

Description

  • The hook is intended to apply a phased fee strcture with configurable limits, cooldowns, and penalties for excessive selling as per the README

  • However, the _beforeSwap function in TokenLaunchHook applies penalties based on swap amounts and cooldowns without distinguishing between buy and sell operations.

function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
// ... existing code ...
bool applyPenalty = false;
if (addressLastSwapBlock[sender] > 0) {
uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[sender];
if (blocksSinceLastSwap < phaseCooldown) {
@> applyPenalty = true; // no check for buy vs sell
}
}
if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
@> applyPenalty = true; // no check for buy vs sell
}
addressSwappedAmount[sender] += swapAmount;
addressLastSwapBlock[sender] = block.number;
uint24 feeOverride = 0;
if (applyPenalty) {
feeOverride = uint24((phasePenaltyBps * 100));
}
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}

Risk

Likelihood: High

  • This will affect all buy swaps during the launch phases, which are critical periods for token distribution and price discovery

  • The issue occurs consistently for every buy transaction processed by the hook

Impact: High

  • Users executing buy swaps are unfairly penalized, potentially discouraging participation in the token launch.

Proof of Concept

function test_PenalizedOnBuy() public {
//ARRANGE: Deal some ETH arrange same swap params
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 swap1BeforeBalance = token.balanceOf(user1);
// ACT: User1 performs a buy swap
vm.startPrank(user1);
token.approve(address(swapRouter), type(uint256).max);
// swapRouter.swap{value: 0.001 ether}(key, params, testSettings, ZERO_BYTES);
swapRouter.swap{value: amountIn}(key, params, testSettings, abi.encode(user1));
uint256 swap1AfterBalance = token.balanceOf(user1);
vm.roll(block.number + 1); // Advance block by one to trigger cooldown
// Perform another buy swap which triggers cooldown penalty
swapRouter.swap{value: amountIn}(key, params, testSettings, abi.encode(user1));
vm.stopPrank();
uint256 swap2AfterBalance = token.balanceOf(user1);
uint256 delta1 = swap1AfterBalance - swap1BeforeBalance;
uint256 delta2 = swap2AfterBalance - swap1AfterBalance;
//Assert: Second swap should yield fewer tokens due to cooldown penalty
assertGt(delta1, delta2, "Second swap should yield fewer tokens due to cooldown penalty");
}

Recommended Mitigation

  • Add a directional check in _beforeSwap to distinguish buy and sell operations using the SwapParams.zeroForOne flag and the pool’s token ordering.

+ import {Currency} from "v4-core/types/Currency.sol";
+ address public immutable launchToken;
constructor(
IPoolManager _poolManager,
uint256 _phase1Duration,
uint256 _phase2Duration,
uint256 _phase1LimitBps,
uint256 _phase2LimitBps,
uint256 _phase1Cooldown,
uint256 _phase2Cooldown,
uint256 _phase1PenaltyBps,
- uint256 _phase2PenaltyBps
+ uint256 _phase2PenaltyBps,
+ address _launchToken
) 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 || _launchToken == address(0)
) revert InvalidConstructorParams();
// ... existing code ...
+ launchToken = _launchToken;
}
+ function _isSell(PoolKey calldata key, bool zeroForOne) internal view returns (bool) {
+ bool IsCurrency0 = Currency.unwrap(key.currency0) == launchToken;
+ if (IsCurrency0) {
+ return zeroForOne;
+ } else {
+ return !zeroForOne;
+ }
+ }
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
// ... existing code ...
bool applyPenalty = false;
+ bool isSell = _isSell(key, params.zeroForOne);
- if (addressLastSwapBlock[sender] > 0) {
+ if(isSell && addressLastSwapBlock[sender] > 0) {
uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[sender];
if (blocksSinceLastSwap < phaseCooldown) {
applyPenalty = true;
}
}
- if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
+ if (isSell && !applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
// ... rest of function ...
}

Support

FAQs

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

Give us feedback!