Vanguard

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

Faulty Phase Transition Tracking Reset - Phase Change Fails to Reset User Quotas

Author Revealed upon completion

Root + Impact

Description

  • The _resetPerAddressTracking() function is intended to clear user trading history when transitioning between launch phases (e.g., from Phase 1 to Phase 2). However, the implementation contains a critical flaw: it only resets the entries for address(0) instead of iterating through and resetting actual user addresses. In Solidity, it is impossible to iterate over all keys in a mapping, and the current implementation effectively resets nothing. Consequently, all users retain their accumulated swap amounts from previous phases, preventing them from accessing fresh allowances in new phases and potentially causing permanent penalties.

// TokenLaunchHook.sol:189-192
function _resetPerAddressTracking() internal {
addressSwappedAmount[address(0)] = 0; // Only resets address(0)!
addressLastSwapBlock[address(0)] = 0; // Does NOT reset real users!
}

Risk

Likelihood:

  • Phase transitions are guaranteed to occur (configured at deployment), and the _resetPerAddressTracking() function is automatically invoked on every phase boundary.

Impact:

Users entering a new phase still carry accumulated swap amounts from previous phases. This means:

  1. Users cannot utilize their new phase allowance (e.g., 3% in Phase 2)

  2. Users may be immediately penalized upon phase entry if they exceeded previous phase limits

  3. The anti-bot protection mechanism becomes a punitive trap for legitimate users

Proof of Concept

The following PoC demonstrates that after a phase transition, user tracking data persists despite the _resetPerAddressTracking() call. We simulate a user trading in Phase 1, then advancing to Phase 2, and verify that their accumulated amount was NOT reset.

function test_BrokenPhaseResetPoC() public {
// 1. Initial swap in Phase 1 to set initialLiquidity
vm.deal(address(this), 10 ether);
swapRouter.swap{value: 0.001 ether}(
key,
SwapParams({
zeroForOne: true,
amountSpecified: -0.001 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
uint256 trappedAmount = hook.addressSwappedAmount(address(swapRouter));
assertGt(trappedAmount, 0, "Phase 1 amount not tracked");
// 2. Advance to Phase 2
vm.roll(block.number + phase1Duration + 1);
// Trigger phase change via a small swap
swapRouter.swap{value: 0.0001 ether}(
key,
SwapParams({
zeroForOne: true,
amountSpecified: -0.0001 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
assertEq(hook.currentPhase(), 2, "Phase did not transition");
// 3. Verify that the tracking was NOT reset for the swap router
uint256 amountInPhase2 = hook.addressSwappedAmount(address(swapRouter));
// If the reset worked, amountInPhase2 should be close to 0.0001 ether (the Phase 2 swap)
// Since it's broken, it will be trappedAmount + 0.0001 ether
assertEq(amountInPhase2, trappedAmount + 0.0001 ether, "Tracking was correctly reset (WHICH IS UNEXPECTED)");
console.log("Bug Confirmed: Swap amounts carried over from Phase 1 to Phase 2");
console.log("Phase 1 Amount:", trappedAmount);
console.log("Current Amount in Phase 2:", amountInPhase2);
}

Recommended Mitigation

The fundamental issue is that Solidity mappings cannot be iterated to reset all entries. Instead of attempting to reset user data, track swap amounts per phase using a nested mapping structure. This allows each phase to have independent accounting without requiring any reset logic.

// Replace single mapping with phase-scoped mapping
mapping(uint256 => mapping(address => uint256)) public phaseAddressSwappedAmount;
mapping(uint256 => mapping(address => uint256)) public phaseAddressLastSwapBlock;
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal override returns (bytes4, BeforeSwapDelta, uint24)
{
// ... phase calculation logic ...
// Update current phase if needed (NO RESET NEEDED)
if (newPhase != currentPhase) {
currentPhase = newPhase;
lastPhaseUpdateBlock = block.number;
}
// Use phase-scoped tracking
uint256 userSwapped = phaseAddressSwappedAmount[currentPhase][sender];
uint256 userLastBlock = phaseAddressLastSwapBlock[currentPhase][sender];
// Apply checks using phase-specific data
if (userLastBlock > 0 && block.number - userLastBlock < phaseCooldown) {
applyPenalty = true;
}
if (!applyPenalty && userSwapped + swapAmount > maxSwapAmount) {
applyPenalty = true;
}
// Update phase-specific tracking
phaseAddressSwappedAmount[currentPhase][sender] += swapAmount;
phaseAddressLastSwapBlock[currentPhase][sender] = block.number;
// ...
}

Support

FAQs

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

Give us feedback!