Vanguard

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

Penalty fees applied to both buys and sells, but documentation specifies only sells should be penalized

Author Revealed upon completion

Penalty fees applied to both buys and sells, but documentation specifies only sells should be penalized

Description

The README explicitly states that the hook is designed to penalize excessive selling:

"Vanguard is a Uniswap V4 hook implementation that provides anti-bot protection for token launches. The protocol implements a phased fee structure to prevent manipulation during the initial launch period, with configurable limits, cooldowns, and penalties for excessive selling."

"This creates a fair launch environment that protects early participants while allowing natural price discovery."

"Phase 1: Strict limits on sell amounts with high penalties for violations"

However, the TokenLaunchHook::_beforeSwap function does not check the swap direction. It applies limits and penalties to all swaps regardless of whether they are buys or sells:

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);
}
uint256 phaseLimitBps = currentPhase == 1 ? phase1LimitBps : phase2LimitBps;
uint256 phaseCooldown = currentPhase == 1 ? phase1Cooldown : phase2Cooldown;
uint256 phasePenaltyBps = currentPhase == 1 ? phase1PenaltyBps : phase2PenaltyBps;
@> // No check on params.zeroForOne to determine swap direction
@> // Limits and penalties are applied to ALL swaps (buys AND sells)
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; // Penalizes buys within cooldown
@> }
@> }
@> if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
@> applyPenalty = true; // Penalizes buys exceeding limit
@> }
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
);
}

The params.zeroForOne field indicates swap direction but is never checked. For a typical token launch pool (ETH/TOKEN):

  • zeroForOne = true: Buying the token (giving ETH, receiving TOKEN)

  • zeroForOne = false: Selling the token (giving TOKEN, receiving ETH)

The anti-bot protection is meant to prevent bots from dumping tokens immediately after launch, not to penalize buyers. Penalizing buyers is counterproductive - it discourages legitimate demand during the launch.

Risk

Likelihood:

  • Every buy transaction during Phase 1 and Phase 2 is affected

  • This is the default behavior for all swaps, not an edge case

Impact:

  • Legitimate buyers are unfairly penalized with fees meant for excessive

  • Discourages buying during launch phases, reducing demand and harming price discovery

  • Contradicts the stated purpose of the protocol

Proof of Concept

The test bellow clearly shows that both buys and sells are included for swapped amount tracking, and cooldown period tracking. This then leads to both contributing to users potentially paying penalties when they go past the specified limits.

To run the following test, apply the following patches in TokenLaunchHook.sol and TokenLaunchHook:_beforeSwap to correctly use the original sender of the swap transaction rather than the router contract.

See https://docs.uniswap.org/contracts/v4/guides/accessing-msg.sender-using-hook

+ interface IMsgSender {
+ function msgSender() external view returns (address);
+ }
contract TokenLaunchHook is BaseHook {
// ...
}
+ error RouterMissingMsgSender(address router);
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
+ try IMsgSender(sender).msgSender() returns (address swapper) {
+ sender = swapper;
+ } catch {
+ revert RouterMissingMsgSender(msg.sender);
+ }
// ...
}

Then add the following test file to the repository, and run forge test --mt test_Fork_BuysAndSellsIncreaseTrackedSwapAmount -vvvv.

Note: Make sure to add an env file with MAINNET_RPC_URL="Your mainnet rpc url"

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/console.sol";
import {Test, console2} from "forge-std/Test.sol";
import {TokenLaunchHook} from "../src/TokenLaunchHook.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {IPositionManager} from "v4-periphery/src/interfaces/IPositionManager.sol";
import {IUniswapV4Router04} from "lib/hookmate/src/interfaces/router/IUniswapV4Router04.sol";
import {AddressConstants} from "lib/hookmate/src/constants/AddressConstants.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {V4Router} from "v4-periphery/src/V4Router.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 {IHooks} from "v4-core/interfaces/IHooks.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {SqrtPriceMath} from "v4-core/libraries/SqrtPriceMath.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 {StateLibrary} from "v4-core/libraries/StateLibrary.sol";
import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/types/BeforeSwapDelta.sol";
import {BalanceDelta} from "v4-core/types/BalanceDelta.sol";
import {Actions} from "v4-periphery/src/libraries/Actions.sol";
import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol";
contract TestTokenLaunchHookFork is Test, ERC1155TokenReceiver {
address constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3;
IPoolManager poolManager;
IPositionManager positionManager;
IUniswapV4Router04 swapRouter;
MockERC20 token;
TokenLaunchHook public antiBotHook;
PoolKey public key;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
address user1 = address(0x1);
address user2 = address(0x2);
address bot1 = address(0xB0B1);
address bot2 = address(0xB0B2);
address deployer = makeAddr("deployer");
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;
string MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
function setUp() public {
vm.createSelectFork(MAINNET_RPC_URL);
poolManager = IPoolManager(AddressConstants.getPoolManagerAddress(block.chainid));
positionManager = IPositionManager(payable(AddressConstants.getPositionManagerAddress(block.chainid)));
swapRouter = IUniswapV4Router04(payable(AddressConstants.getV4SwapRouterAddress(block.chainid)));
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 1000 ether);
token.mint(user1, 1000 ether);
token.mint(user2, 1000 ether);
token.mint(bot1, 1000 ether);
token.mint(bot2, 1000 ether);
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory constructorArgs = abi.encode(
poolManager,
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}(
poolManager,
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(poolManager), type(uint256).max);
vm.prank(user1);
token.approve(address(swapRouter), type(uint256).max);
vm.prank(user2);
token.approve(address(swapRouter), type(uint256).max);
vm.prank(bot1);
token.approve(address(swapRouter), type(uint256).max);
vm.prank(bot2);
token.approve(address(swapRouter), type(uint256).max);
key = PoolKey({
currency0: ethCurrency,
currency1: tokenCurrency,
fee: LPFeeLibrary.DYNAMIC_FEE_FLAG,
tickSpacing: 60,
hooks: IHooks(address(antiBotHook))
});
vm.startPrank(deployer);
poolManager.initialize(key, SQRT_PRICE_1_1_s);
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint256 ethToAdd = 100 ether;
uint128 liquidityDelta =
LiquidityAmounts.getLiquidityForAmount0(SQRT_PRICE_1_1_s, sqrtPriceAtTickUpper, ethToAdd);
vm.deal(deployer, 200 ether);
token.mint(deployer, 2000 ether);
// Approve Permit2, then approve PositionManager via Permit2
token.approve(PERMIT2, type(uint256).max);
IAllowanceTransfer(PERMIT2)
.approve(address(token), address(positionManager), type(uint160).max, type(uint48).max);
// Use PositionManager to add liquidity (handles unlock callback internally)
bytes memory actions = abi.encodePacked(uint8(Actions.MINT_POSITION), uint8(Actions.SETTLE_PAIR));
bytes[] memory params = new bytes[](2);
params[0] = abi.encode(
key, -60, 60, uint256(liquidityDelta), type(uint256).max, type(uint256).max, deployer, bytes("")
);
params[1] = abi.encode(ethCurrency, tokenCurrency);
positionManager.modifyLiquidities{value: ethToAdd}(abi.encode(actions, params), block.timestamp + 60);
vm.stopPrank();
}
function test_Fork_BuysAndSellsIncreaseTrackedSwapAmount() public {
uint256 buyAmount = 0.1 ether;
uint256 sellAmount = 1 ether;
// Deal user buy funds
vm.deal(user1, buyAmount);
vm.startPrank(user1);
// Execute buy swap
swapRouter.swap{value: buyAmount}(-int256(buyAmount), 0, true, key, bytes(""), user1, block.timestamp + 60);
// Advance past the cooldown period
vm.roll(block.number + phase1Cooldown + 1);
// Execute sell swap
swapRouter.swap{value: 0}(-int256(sellAmount), 0, false, key, bytes(""), user1, block.timestamp + 60);
vm.stopPrank();
// Assert that the tracked swap amount is the sum of the buy and sell amounts
assertEq(antiBotHook.addressSwappedAmount(address(user1)), buyAmount + sellAmount);
}
}

Recommended Mitigation

Add a per-pool mapping to track the launch token and check swap direction:

Note: This mitigation assumes the broader issue of shared state across pools (see separate finding) is also addressed by converting all state variables to PoolId-keyed mappings.

+ // Add state variable to track which currency is the launch token per pool
+ mapping(PoolId => Currency) public launchToken;
function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
+ PoolId id = key.toId();
- launchStartBlock = block.number;
+ launchStartBlock[id] = block.number;
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
- initialLiquidity = uint256(liquidity);
+ initialLiquidity[id] = uint256(liquidity);
- currentPhase = 1;
+ currentPhase[id] = 1;
- lastPhaseUpdateBlock = block.number;
+ lastPhaseUpdateBlock[id] = block.number;
+ // Store the launch token - typically currency1 in an ETH/TOKEN pair
+ launchToken[id] = key.currency1;
return BaseHook.afterInitialize.selector;
}
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
+ PoolId id = key.toId();
- if (launchStartBlock == 0) revert PoolNotInitialized();
+ if (launchStartBlock[id] == 0) revert PoolNotInitialized();
+ // Determine if this is a sell of the launch token
+ // zeroForOne = true means selling currency0 for currency1
+ // zeroForOne = false means selling currency1 for currency0
+ Currency poolLaunchToken = launchToken[id];
+ bool isSellingLaunchToken;
+ if (Currency.unwrap(poolLaunchToken) == Currency.unwrap(key.currency0)) {
+ isSellingLaunchToken = params.zeroForOne; // Selling currency0
+ } else {
+ isSellingLaunchToken = !params.zeroForOne; // Selling currency1
+ }
+
+ // Only apply anti-bot protection to sells, not buys
+ if (!isSellingLaunchToken) {
+ return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, LPFeeLibrary.OVERRIDE_FEE_FLAG);
+ }
// ... rest of the function applies to sells only, accessing state via id

Support

FAQs

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

Give us feedback!