Vanguard

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

Per-address tracking tracks router address instead of actual users, breaking hook anti-bot functionality

Author Revealed upon completion

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:

  1. All users share the same limits

  2. Individual user tracking is completely broken

  3. 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)
{
// ...
// 'sender' is the router, not the actual user
@> addressSwappedAmount[sender] += swapAmount;
@> addressLastSwapBlock[sender] = block.number;
// ...
}

The flow is:

  1. User calls swapRouter.swap()

  2. Router calls PoolManager.swap()

  3. PoolManager calls hook.beforeSwap(sender=router, ...)

  4. 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

  1. Two separate users execute swaps

  2. Both users have zero recorded swapAmount

  3. 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;
// Swap 3 times from the regular user wallet
SwapParams memory params = SwapParams({
zeroForOne: false, // false = sell token (currency1) for ETH (currency0)
amountSpecified: -int256(swapAmount), // negative = exact input (selling this amount of tokens)
sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 // opposite direction limit
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
// Execute swap 1
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:

  1. 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) {
  1. Define an interface for trusted Routers.

+ interface IMsgSender {
+ function msgSender() external view returns (address);
+ }
  1. 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);
+ }
  1. 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);
+ }
// ...
}

Support

FAQs

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

Give us feedback!