Vanguard

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

Phase Transition Fails to Reset User Limits due to Incorrect Mapping Clearing

Author Revealed upon completion

Root + Impact

Root Cause: The _resetPerAddressTracking function is intended to reset user limits when the launch phase changes. However, it only manually resets the mapping for address(0), leaving all other user data intact.
Impact: User swap volume from Phase 1 carries over to Phase 2. Since Phase 2 typically introduces higher limits, users who participated in Phase 1 start with a disadvantage (their previous volume is counted against the new limit), effectively reducing their allowed allowance in Phase 2. In strict scenarios, this might prevent legitimate participation in Phase 2.

Description

The contract attempts to reset tracking in _beforeSwap:

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

However, the implementation of _resetPerAddressTracking is flawed:

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

Since mappings in Solidity cannot be iterated or cleared in O(1) without an epoch/generation counter, this function does nothing for actual users.

Risk

Likelihood:
High. Always happens on phase transition.

Impact:
High. It distorts the economic parameters of the launch. Users are more restricted than intended in Phase 2.

vulnerability Path

Steps:

  1. User A swaps 100 tokens in Phase 1. addressSwappedAmount[A] = 100.

  2. Time passes, Phase 1 ends.

  3. User A swaps in Phase 2.

  4. _beforeSwap detects phase change, calls _resetPerAddressTracking.

  5. _resetPerAddressTracking clears address(0), but addressSwappedAmount[A] remains 100.

  6. User A's swap in Phase 2 is checked against Limit2 - 100 instead of Limit2.

Proof of Concept

You can run this test using: forge test --match-contract PoC_Finding3_ResetFailure

// test/PoC_Finding3_ResetFailure.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";
import {TokenLaunchHook} from "../src/TokenLaunchHook.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 {PoolManager} from "v4-core/PoolManager.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {PoolId} from "v4-core/types/PoolId.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol";
contract PoC_Finding3_ResetFailure is Test, Deployers {
using CurrencyLibrary for Currency;
MockERC20 token;
TokenLaunchHook public antiBotHook;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
address user1 = address(0x1111);
// Constants from script
uint256 constant PHASE1_DURATION = 100;
uint256 constant PHASE2_DURATION = 200;
uint256 constant PHASE1_LIMIT_BPS = 100; // 1%
uint256 constant PHASE2_LIMIT_BPS = 300;
uint256 constant PHASE1_COOLDOWN = 5;
uint256 constant PHASE2_COOLDOWN = 3;
uint256 constant PHASE1_PENALTY_BPS = 500;
uint256 constant PHASE2_PENALTY_BPS = 200;
function setUp() public {
deployFreshManagerAndRouters();
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 10000 ether);
token.mint(user1, 1000 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.deal(user1, 100 ether);
}
function deployCorrectly() internal {
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory constructorArgs = abi.encode(
manager,
PHASE1_DURATION, PHASE2_DURATION,
PHASE1_LIMIT_BPS, PHASE2_LIMIT_BPS,
PHASE1_COOLDOWN, PHASE2_COOLDOWN,
PHASE1_PENALTY_BPS, PHASE2_PENALTY_BPS
);
// CORRECT flags
uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG);
(address hookAddress, bytes32 salt) = HookMiner.find(address(this), flags, creationCode, constructorArgs);
antiBotHook = new TokenLaunchHook{salt: salt}(
manager,
PHASE1_DURATION, PHASE2_DURATION,
PHASE1_LIMIT_BPS, PHASE2_LIMIT_BPS,
PHASE1_COOLDOWN, PHASE2_COOLDOWN,
PHASE1_PENALTY_BPS, PHASE2_PENALTY_BPS
);
(key, ) = initPool(ethCurrency, tokenCurrency, antiBotHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1);
// Add Liquidity
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint256 ethToAdd = 10 ether;
uint128 liquidityDelta = LiquidityAmounts.getLiquidityForAmount0(SQRT_PRICE_1_1, sqrtPriceAtTickUpper, ethToAdd);
modifyLiquidityRouter.modifyLiquidity{value: ethToAdd}(
key,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(liquidityDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
}
// 3. PoC for Reset Failure
function test_PoC_ResetFailure() public {
deployCorrectly();
uint256 swapAmount = 0.01 ether;
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(swapAmount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings = PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
// Phase 1: User 1 swaps
vm.startPrank(user1);
swapRouter.swap{value: swapAmount}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 usagePhase1 = antiBotHook.addressSwappedAmount(address(swapRouter));
// Move to Phase 2
vm.roll(block.number + PHASE1_DURATION + 1);
// Trigger swap to update phase
vm.startPrank(user1);
swapRouter.swap{value: swapAmount}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
// Check if usage was reset
uint256 usagePhase2 = antiBotHook.addressSwappedAmount(address(swapRouter));
// Usage should be accumulated because the reset only clears address(0)
assertEq(usagePhase2, usagePhase1 + swapAmount, "Usage should accumulate across phases due to broken reset");
}
}

Test Result

forge test --match-path test/PoC_Finding3_ResetFailure.t.sol -vv
Ran 1 test for test/PoC_Finding3_ResetFailure.t.sol:PoC_Finding3_ResetFailure
[PASS] test_PoC_ResetFailure() (gas: 3322472)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 24.75ms (22.53ms CPU time)

Recommended Mitigation

Use an epoch/generation counter to invalidate stale data without iterating.

  1. Add a global currentPhaseId (effectively currentPhase).

  2. Change mappings to mapping(address => mapping(uint256 => uint256)) public addressSwappedAmount; where the inner key is the phase ID.

  3. Or, similarly mapping(address => PhaseData).

- mapping(address => uint256) public addressSwappedAmount;
+ mapping(address => mapping(uint256 => uint256)) public addressSwappedAmount;
// In _beforeSwap
- addressSwappedAmount[sender] += swapAmount;
+ addressSwappedAmount[sender][currentPhase] += swapAmount;
// In usage check
- if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
+ if (!applyPenalty && addressSwappedAmount[sender][currentPhase] + swapAmount > maxSwapAmount) {

Support

FAQs

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

Give us feedback!