Vanguard

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

TokenLaunchHook::_resetPerAddressTracking only resets `address(0)`, leaving all user tracked data persistent across phases

Author Revealed upon completion

TokenLaunchHook::_resetPerAddressTracking only resets address(0), leaving all user tracked data persistent across phases

Description

The _resetPerAddressTracking function is intended to reset per-address swap tracking when the protocol transitions between phases. However, the implementation only resets the mappings for address(0):

function _resetPerAddressTracking() internal {
@> addressSwappedAmount[address(0)] = 0;
@> addressLastSwapBlock[address(0)] = 0;
}

Since no actual user ever swaps from address(0), this function does not work as intended. The per-address tracking data (addressSwappedAmount and addressLastSwapBlock) for all real users persists across phase transitions.

This is called during phase transitions in TokenLaunchHook::_beforeSwap:

if (newPhase != currentPhase) {
@> _resetPerAddressTracking();
currentPhase = newPhase;
lastPhaseUpdateBlock = block.number;
}

The intended behavior is that users get fresh limits and cooldowns when entering a new phase. Instead, their Phase 1 activity carries into Phase 2, causing users who legitimately used their Phase 1 allocation to be unfairly penalized in Phase 2.

Risk

Likelihood:

  • This affects every user who swapped during Phase 1 and then swaps in Phase 2

  • The bug triggers on every phase transition for every active user

Impact:

  • Users who used their full Phase 1 limit will immediately exceed their Phase 2 limit and pay penalties

  • Cooldown periods from Phase 1 persist, potentially blocking users from swapping early in Phase 2

  • The intended "fresh start" for each phase is broken

Proof of Concept

  1. A user swaps in phase 1

  2. A user then does another swap in phase 2, their swap limit should be reset as we have entered a new phase

  3. The swap amount they did in phase 1 has carried over

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_ResetTrackingDoesNothing -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_ResetTrackingDoesNothing() public {
// User swaps during Phase 1
uint256 swapAmount = 5 ether;
vm.prank(user1);
swapRouter.swap{value: 0}(-int256(swapAmount), 0, false, key, bytes(""), user1, block.timestamp + 60);
// Record tracked amount (router is tracked, not user - separate bug)
uint256 trackedBefore = antiBotHook.addressSwappedAmount(address(user1));
assertGt(trackedBefore, 0, "Should have tracked the swap");
// Advance to Phase 2 - should reset tracking
vm.roll(block.number + phase1Duration + 1);
// Execute swap to trigger phase transition
vm.prank(user1);
swapRouter.swap{value: 0}(-int256(swapAmount), 0, false, key, bytes(""), user1, block.timestamp + 60);
// Tracking was NOT reset - it accumulated instead
uint256 trackedAfter = antiBotHook.addressSwappedAmount(address(user1));
assertEq(trackedAfter, trackedBefore + swapAmount, "BUG: Tracking persisted across phases");
}
}

Recommended Mitigation

Consider using a nested mapping that includes both PoolId and phase number:

+ interface IMsgSender {
+ function msgSender() external view returns (address);
+ }
- mapping(address => uint256) public addressSwappedAmount;
- mapping(address => uint256) public addressLastSwapBlock;
+ // Keyed by: PoolId => phase => address => amount
+ mapping(PoolId => mapping(uint256 => mapping(address => uint256))) public addressSwappedAmount;
+ mapping(PoolId => mapping(uint256 => mapping(address => uint256))) public addressLastSwapBlock;
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
+ PoolId poolId = key.toId();
+ try IMsgSender(sender).msgSender() returns (address swapper) {
+ sender = swapper;
+ } catch {
+ revert RouterMissingMsgSender(sender);
+ }
if (initialLiquidity == 0) {
- uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
+ uint128 liquidity = StateLibrary.getLiquidity(poolManager, poolId);
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 (addressLastSwapBlock[poolId][currentPhase][sender] > 0) {
+ uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[poolId][currentPhase][sender];
if (blocksSinceLastSwap < phaseCooldown) {
applyPenalty = true;
}
}
- if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
+ if (!applyPenalty && addressSwappedAmount[poolId][currentPhase][sender] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
- addressSwappedAmount[sender] += swapAmount;
- addressLastSwapBlock[sender] = block.number;
+ addressSwappedAmount[poolId][currentPhase][sender] += swapAmount;
+ addressLastSwapBlock[poolId][currentPhase][sender] = block.number;
uint24 feeOverride = 0;
if (applyPenalty) {
feeOverride = uint24((phasePenaltyBps * 100));
}
return
(
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
feeOverride | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}
- function _resetPerAddressTracking() internal {
- addressSwappedAmount[address(0)] = 0;
- addressLastSwapBlock[address(0)] = 0;
- }

Support

FAQs

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

Give us feedback!