Vanguard

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

Broken tracking reset mechanism

Author Revealed upon completion

Description

The TokenLaunchHook implements a phased anti-bot system where user swap limits reset when transitioning between phases (e.g., from Phase 1's 1% limit to Phase 2's 5% limit). The _resetPerAddressTracking() function is called during phase transitions to clear accumulated user swap amounts, giving users a fresh start with the new phase's higher limits.

However, the reset function only clears tracking data for address(0) instead of actual users, causing all user swap amounts to persist across phase transitions. This means users who swapped in Phase 1 have those amounts counted against their Phase 2 limits, effectively reducing their available swap capacity in subsequent phases.

function _resetPerAddressTracking() internal {
// @> Only resets address(0), not actual user addresses
addressSwappedAmount[address(0)] = 0;
addressLastSwapBlock[address(0)] = 0;
}
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
// ...
if (newPhase != currentPhase) {
// @> Called during phase transition but doesn't actually reset users
_resetPerAddressTracking();
currentPhase = newPhase;
lastPhaseUpdateBlock = block.number;
}

Risk

Likelihood: High

  • The bug triggers on every phase transition (Phase 1 → 2, Phase 2 → 3) for every user who swapped in the previous phase

  • Any user who legitimately swaps in Phase 1 will be affected when Phase 2 begins

Impact: Medium

  • Users who legitimately participated in Phase 1 (swapping 1% of liquidity) cannot utilize their full Phase 2 limit (5% of liquidity) - they can only swap an additional 4%

Proof of Concept

Add this test to the TokenLaunchHookUnit.t.sol:

function test_BrokenTrackingReset() public {
vm.deal(user1, 10 ether);
vm.startPrank(user1);
// Phase 1: User swaps 1% of liquidity (at the limit)
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(0.001 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
swapRouter.swap{value: 0.001 ether}(key, params, testSettings, ZERO_BYTES);
uint256 phase1Amount = antiBotHook.addressSwappedAmount(address(swapRouter));
console.log("Phase 1 swapped amount:", phase1Amount);
// Transition to Phase 2 (should reset limits)
vm.roll(block.number + phase1Duration + 1);
// Trigger phase transition
swapRouter.swap{value: 0.001 ether}(key, params, testSettings, ZERO_BYTES);
uint256 phase2Amount = antiBotHook.addressSwappedAmount(address(swapRouter));
console.log("Phase 2 swapped amount:", phase2Amount);
// Vulnerability: phase2Amount > phase1Amount (tracking was NOT reset)
assertGt(phase2Amount, phase1Amount, "Tracking persists across phases!");
vm.stopPrank();
}

Output:

Phase 1 swapped amount: 1000000000000000
Phase 2 swapped amount: 2000000000000000

Tracking persists across phases!

Recommended Mitigation

Implement an epoch-based tracking system that invalidates previous phase data without requiring expensive storage resets:

+ uint256 public currentEpoch;
+ mapping(address => mapping(uint256 => uint256)) public addressSwappedAmountByEpoch;
+ mapping(address => mapping(uint256 => uint256)) public addressLastSwapBlockByEpoch;
- mapping(address => uint256) public addressSwappedAmount;
- mapping(address => uint256) public addressLastSwapBlock;
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
// ... existing code ...
if (newPhase != currentPhase) {
- _resetPerAddressTracking();
+ currentEpoch++; // Increment epoch to invalidate all previous tracking
currentPhase = newPhase;
lastPhaseUpdateBlock = block.number;
}
// ... existing code ...
- if (addressLastSwapBlock[sender] > 0) {
- uint256 blocksSinceLastSwap = block.number - addressLastSwapBlock[sender];
+ if (addressLastSwapBlockByEpoch[sender][currentEpoch] > 0) {
+ uint256 blocksSinceLastSwap = block.number - addressLastSwapBlockByEpoch[sender][currentEpoch];
if (blocksSinceLastSwap < phaseCooldown) {
applyPenalty = true;
}
}
- if (!applyPenalty && addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
+ if (!applyPenalty && addressSwappedAmountByEpoch[sender][currentEpoch] + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
- addressSwappedAmount[sender] += swapAmount;
- addressLastSwapBlock[sender] = block.number;
+ addressSwappedAmountByEpoch[sender][currentEpoch] += swapAmount;
+ addressLastSwapBlockByEpoch[sender][currentEpoch] = block.number;
// ... rest of function ...
}
- function _resetPerAddressTracking() internal {
- addressSwappedAmount[address(0)] = 0;
- addressLastSwapBlock[address(0)] = 0;
- }

This approach:

  • Efficiently resets tracking by incrementing an epoch counter instead of clearing storage

  • Ensures each phase uses isolated tracking data

  • Maintains gas efficiency by avoiding storage deletion loops

  • Provides users with the intended fresh limits for each phase

Support

FAQs

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

Give us feedback!