Per-address tracking tracks router address instead of actual users, breaking anti-bot functionality
Description
The TokenLaunchHook::_beforeSwap uses the sender parameter to track swap amounts and cooldowns per address. However, in Uniswap V4, the sender passed to hooks is msg.sender to PoolManager.swap() - which is typically a router contract, not the end user.
When users swap via standard routers (UniversalRouter, V4Router, aggregators), all swaps are attributed to the router's address, not the individual users. This means:
All users share the same limits
Individual user tracking is completely broken
The anti-bot protection provides no per-user enforcement
@> function beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
@> addressSwappedAmount[sender] += swapAmount;
@> addressLastSwapBlock[sender] = block.number;
}
The flow is:
User calls swapRouter.swap()
Router calls PoolManager.swap()
PoolManager calls hook.beforeSwap(sender=router, ...)
Hook tracks addressSwappedAmount[router], not addressSwappedAmount[user]
Risk
Likelihood:
Every swap through any router (which is the standard way to interact with v4) will trigger this issue
The issue is inherent to the design - it's not an edge case but the default behavior
The test suite itself acknowledges this with comments like "since sender is the router"
Impact:
Per-user tracking is completely non-functional
All users who swap share the same limit, meaning limits will be hit very quickly and then users will unfairly pay penalty fees
Proof of Concept
Two separate users execute swaps
Both users have zero recorded swapAmount
The Router has a recorded swapAmount equal to both of the users swap values
Add the following test to TokenLaunchHookUnit.t.sol:
function test_RouterIsTrackedNotUser() public {
uint256 swapAmount = 0.1 ether;
SwapParams memory params = SwapParams({
zeroForOne: false,
amountSpecified: -int256(swapAmount),
sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
vm.startPrank(user1);
swapRouter.swap{value: 0}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
vm.startPrank(user2);
swapRouter.swap{value: 0}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 swappedAmountUser1 = antiBotHook.addressSwappedAmount(user1);
console.log("Swapped amount user1:", swappedAmountUser1);
uint256 swappedAmountUser2 = antiBotHook.addressSwappedAmount(user2);
console.log("Swapped amount user2:", swappedAmountUser2);
uint256 swappedAmountRouter = antiBotHook.addressSwappedAmount(address(swapRouter));
console.log("Swapped amount router:", swappedAmountRouter);
assertEq(swappedAmountUser1, 0);
assertEq(swappedAmountUser2, 0);
assertEq(swappedAmountRouter, swapAmount * 2);
}
Recommended Mitigation
The mitigation for this requires multiple steps documented below:
Make the TokenLaunchHook contract ownable, this aligns with the protocol README but is currently not implemented in contract. Implement this via the Openzeppelin Ownable2Step helper contract.
Note: The relevant Openzeppelin package can be installed by running forge install Openzeppelin/openzeppelin-contracts
+ import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"
constructor(
IPoolManager _poolManager,
uint256 _phase1Duration,
uint256 _phase2Duration,
uint256 _phase1LimitBps,
uint256 _phase2LimitBps,
uint256 _phase1Cooldown,
uint256 _phase2Cooldown,
uint256 _phase1PenaltyBps,
uint256 _phase2PenaltyBps
- ) BaseHook(_poolManager) {
+ ) BaseHook(_poolManager) Ownable(msg.sender) {
Define an interface for trusted Routers.
+ interface IMsgSender {
+ function msgSender() external view returns (address);
+ }
Create a mapping for trusted routers, and owner administrative functions to update the mapping
+ event VerifiedRouterAdded(address router);
+ event VerifiedRouterRemoved(address router);
+ mapping(address swapRouter => bool approved) public verifiedRouters;
+ function addRouter(address _router) external onlyOwner {
+ verifiedRouters[_router] = true;
+ emit VerifiedRouterAdded(_router);
+ }
+ function removeRouter(address _router) external onlyOwner {
+ verifiedRouters[_router] = false;
+ emit VerifiedRouterRemoved(_router);
+ }
In TokenLaunchHook::_beforeSwap call msgSender on the sender passed into the function. Use this to retrieve the original EOA, and use it throughout the function.
+ error NotVerifiedRouter();
+ 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();
+ if (!verifiedRouters[sender]) revert NotVerifiedRouter();
+ try IMsgSender(sender).msgSender() returns (address swapper) {
+ sender = swapper;
+ } catch {
+ revert RouterMissingMsgSender(msg.sender);
+ }
// ...
}