Vanguard

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

Title: _resetPerAddressTracking fails to clear per-user mapping leading to stale swap limits across phases

Author Revealed upon completion

Root + Impact

Description

The TokenLaunchHook manages anti-bot protections through three sequential phases. A core requirement is that user swap totals (addressSwappedAmount) must reset when transitioning between phases to allow participants access to new limits.


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

Risk

Likelihood:

  • Likelihood: High. Phase transitions are certain (block-based). The failure to reset user state is a mathematical certainty for 100% of participants.



  • Impact** **1: Financial Loss: Users incorrectly pay phasePenaltyBps because the hook views their stale Phase 1 volume as exceeding Phase 2 limits. 2. Denial of Service (DoS): Users are unable to trade their full Phase 2 allocation if their Phase 1 activity already exhausted the limit.


Proof of Concept

The following test demonstrates that the contract fails to clear user state after a phase transition. Even after currentPhase updates, user1 retains their previous volume.

function test_resetPerAddressTracking_bug() public {
MockERC20 weth = new MockERC20("TOKEN", "TKN", 18);
MockSwapRouter _swapRouter;
_swapRouter = new MockSwapRouter(address(antiBotHook));
weth.mint(address(_swapRouter), 100 ether);
token.mint(address(_swapRouter), 100 ether);
address[] memory path = new address[](2);
path[0] = address(token); // token you sell
path[1] = address(weth); // ETH (or WETH) you receive
token.mint(user1, 10 ether);
weth.mint(user1, 10 ether);
token.mint(user2, 10 ether);
weth.mint(user2, 10 ether);
vm.prank(user1);
token.approve(address(_swapRouter), type(uint256).max);
weth.approve(address(_swapRouter), type(uint256).max);
vm.prank(user2);
token.approve(address(_swapRouter), type(uint256).max);
weth.approve(address(_swapRouter), type(uint256).max);
// 1. User swaps in Phase 1
vm.prank(user1);
_swapRouter.swapExactTokensForTokens(1 ether, 0, path, user1, block.timestamp);
uint256 swappedPhase1 = antiBotHook.addressSwappedAmount(user1);
assert(swappedPhase1 > 0);
// 2. Advance blocks to Phase 2
vm.roll(block.number + phase1Duration + 1);
// 3. Trigger phase update by next swap
vm.prank(user2);
_swapRouter.swapExactTokensForTokens(1 ether, 0, path, user2, block.timestamp);
// 4. User 1 swaps again in Phase 2.
vm.prank(user1);
_swapRouter.swapExactTokensForTokens(0.1 ether, 0, path, user1, block.timestamp);
// 5. Check the balance.
uint256 finalAmount = antiBotHook.addressSwappedAmount(user1);
console.log("User 1 Total after Phase 2 swap:", finalAmount);
// This will now PASS if your fix is correct
assertEq(finalAmount, 0.1 ether, "Lazy reset failed to wipe Phase 1 volume");
}
}
import {BeforeSwapDelta} from "v4-core/types/BeforeSwapDelta.sol";
interface IAntiBotHookHacker {
function beforeSwap(address sender, PoolKey memory key, SwapParams memory params, bytes memory hookData)
external
returns (bytes4, BeforeSwapDelta, uint24);
}
interface Vm {
function prank(address) external;
}
contract MockSwapRouter {
TokenLaunchHook public antiBotHook;
Vm public constant vm = Vm(address(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D));
constructor(address _hook) {
antiBotHook = TokenLaunchHook(_hook);
}
function swapExactTokensForTokens(uint256 amountIn, uint256, address[] calldata path, address to, uint256)
external
returns (uint256[] memory amounts)
{
PoolKey memory key = PoolKey({
currency0: Currency.wrap(path[0]),
currency1: Currency.wrap(path[1]),
fee: 3000,
tickSpacing: 60,
hooks: antiBotHook
});
SwapParams memory params =
SwapParams({zeroForOne: true, amountSpecified: -int256(amountIn), sqrtPriceLimitX96: 0});
address manager = address(antiBotHook.poolManager());
vm.prank(manager);
IAntiBotHookHacker(address(antiBotHook)).beforeSwap(msg.sender, key, params, "");
MockERC20(path[0]).transferFrom(msg.sender, address(this), amountIn);
MockERC20(path[1]).transfer(to, amountIn);
amounts = new uint256[](2);
amounts[0] = amountIn;
amounts[1] = amountIn;
}
}

Recommended Mitigation

Since a global mapping reset is O(n) and gas-prohibitive, implement a Lazy Reset pattern. This shifts the reset responsibility to the _beforeSwap call of each individual user.

Mechanism:

  1. Introduce a mapping(address => uint256) public addressLastPhase to track the last phase ID a user interacted with.

  2. Upon interaction, if the user's recorded phase is less than the global currentPhase, reset their specific storage slots before processing the swap.

+ mapping(address => uint256) public addressLastPhase;
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
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);
}
+ if (addressLastPhase[sender] < currentPhase) {
+ addressSwappedAmount[sender] = 0;
+ addressLastPhase[sender] = currentPhase;
+ }
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 (blocksSinceLastSwap < phaseCooldown) {
applyPenalty = true;
}
}
if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
addressSwappedAmount[sender] += swapAmount;
addressLastSwapBlock[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!