Vanguard

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

Limits/penalties tracked by sender (msg.sender) are trivially bypassed

Author Revealed upon completion

Description

  • The hook is supposed to enforce per‑user limits/cooldowns/penalties during the launch phases so that each trader is constrained independently. In Uniswap v4, the beforeSwap callback receives a sender argument, but in standard flows this is the router contract, not the end user.

  • In TokenLaunchHook._beforeSwap, the contract keys all enforcement to the sender parameter (and stores to addressSwappedAmount[sender], addressLastSwapBlock[sender], etc.). Because sender is the swap router, all users are aggregated under the same address; conversely, an attacker can bypass enforcement by using a different router address (e.g., a custom router/adapter), instantly resetting their counters and cooldowns. This design makes “per‑user” enforcement ineffective. The project’s own tests confirm that tracking is recorded for address(swapRouter) instead of the actual users.

function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal override returns (bytes4, BeforeSwapDelta, uint24)
{
...
uint256 swapAmount = params.amountSpecified < 0
? uint256(-params.amountSpecified)
: uint256(params.amountSpecified);
// @> BUG: tracking keyed by `sender` (the router), not the end-user
addressSwappedAmount[sender] += swapAmount;
addressLastSwapBlock[sender] = block.number;
...
}

Risk

Likelihood: High

  • Always in normal routing: Standard v4 swaps go through a router, so sender is the router and all users collapse into a single tracked address.

  • Easy to bypass by using another router: An attacker can deploy or select a different router/adapter (a different sender) and immediately evade accumulated limits and cooldowns.

Impact: High

  • Per‑user limits do not work: Legitimate users get throttled/penalized based on unrelated users’ activity through the same router, while attackers avoid limits by switching routers.

  • Launch protection weakened: The intended fairness controls during the launch phases are ineffective in practice.

Proof of Concept

  • Create SenderTrackingBypass.t.sol under test directory and copy code below.

  • Run command forge test --mt test_SenderAggregation_AllUsersCountedUnderRouter -vvvv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol";
import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {TokenLaunchHook} from "../src/TokenLaunchHook.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {ERC1155TokenReceiver} from "solmate/src/tokens/ERC1155.sol";
contract SenderTrackingBypassTest is Test, Deployers, ERC1155TokenReceiver {
TokenLaunchHook hook;
MockERC20 token;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
uint256 phase1Duration = 100;
uint256 phase2Duration = 100;
uint256 phase1LimitBps = 100;
uint256 phase2LimitBps = 500;
uint256 phase1Cooldown = 5;
uint256 phase2Cooldown = 2;
uint256 phase1PenaltyBps = 1000;
uint256 phase2PenaltyBps = 500;
address user1 = address(0x1111);
address user2 = address(0x2222);
function setUp() public {
deployFreshManagerAndRouters();
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
// fund users and approvals
token.mint(address(this), 1_000 ether);
token.mint(user1, 100 ether);
token.mint(user2, 100 ether);
token.approve(address(swapRouter), type(uint256).max);
token.approve(address(modifyLiquidityRouter), type(uint256).max);
vm.prank(user1);
token.approve(address(swapRouter), type(uint256).max);
vm.prank(user2);
token.approve(address(swapRouter), type(uint256).max);
// Mine hook with AFTER_INITIALIZE + BEFORE_SWAP (matches getHookPermissions)
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory args = abi.encode(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG);
(address mined, bytes32 salt) = HookMiner.find(address(this), flags, creationCode, args);
hook = new TokenLaunchHook{salt: salt}(
IPoolManager(manager),
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
require(address(hook) == mined, "Hook address mismatch");
// Initialize pool & add liquidity
(key,) = initPool(ethCurrency, tokenCurrency, hook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1);
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint256 ethToAdd = 10 ether;
vm.deal(address(this), ethToAdd + 1 ether); // fund test for settle buffer
uint128 liqDelta = LiquidityAmounts.getLiquidityForAmount0(
SQRT_PRICE_1_1, sqrtPriceAtTickUpper, ethToAdd
);
modifyLiquidityRouter.modifyLiquidity{value: ethToAdd + 0.5 ether}(
key,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(liqDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
}
function test_SenderAggregation_AllUsersCountedUnderRouter() public {
uint256 swapAmount = 0.001 ether;
// user1 swap via router
vm.deal(user1, 1 ether);
vm.startPrank(user1);
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(swapAmount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory settings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
swapRouter.swap{value: swapAmount}(key, params, settings, ZERO_BYTES);
vm.stopPrank();
// user2 swap via the SAME router
vm.deal(user2, 1 ether);
vm.startPrank(user2);
swapRouter.swap{value: swapAmount}(key, params, settings, ZERO_BYTES);
vm.stopPrank();
// EXPECTED IF PER-USER: users would have non-zero usage under their own addresses
// ACTUAL: both users are zero; only router accumulates
assertEq(hook.addressSwappedAmount(user1), 0, "user1 not tracked individually");
assertEq(hook.addressSwappedAmount(user2), 0, "user2 not tracked individually");
assertEq(
hook.addressSwappedAmount(address(swapRouter)),
2 * swapAmount,
"all usage aggregated under router address"
);
}
}

Recommended Mitigation

  • Require routers/adapters to encode the end‑user address into hookData and key enforcement on that value; fall back to sender only if hookData is missing. This is explicit, router‑agnostic, and compatible with AA/relayers.

- function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
+ function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata hookData)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
+ // decode end-user from hookData (first 20 bytes), fallback to router `sender`
+ address trader = sender;
+ if (hookData.length >= 20) {
+ assembly {
+ trader := shr(96, calldataload(hookData.offset))
+ }
+ }
...
- addressSwappedAmount[sender] += swapAmount;
- addressLastSwapBlock[sender] = block.number;
+ addressSwappedAmount[trader] += swapAmount;
+ addressLastSwapBlock[trader] = block.number;
...
}

Support

FAQs

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

Give us feedback!