Skip to content

Latest commit

 

History

History
488 lines (370 loc) · 18.6 KB

File metadata and controls

488 lines (370 loc) · 18.6 KB

Uniswap V4 Dynamic Fee Hook

Your bank charges you more when the market is crashing. Shouldn't your DEX do the same?

Traditional AMMs charge a flat fee — 0.3%, always, whether it's a quiet Tuesday afternoon or a black swan event.
This Uniswap V4 hook fixes that. It reads on-chain volatility after every swap and sets the fee for the next one — automatically.

Calm market → 0.05%  ·  Volatile market → 1.00%  ·  No oracle. No governance. Pure on-chain math.

How It WorksLive ResultsArchitectureGetting StartedTestingGas BenchmarksDeployment

Solidity Foundry Solady Uniswap V4 Sepolia MIT License


The Problem

Uniswap V3 pools use fixed fee tiers (0.01%, 0.05%, 0.30%, 1.00%). This creates a lose-lose situation for liquidity providers:

  • In calm markets, a 0.30% fee is too expensive — traders go elsewhere, LPs miss volume.
  • In volatile markets, a 0.30% fee is too cheap — LPs absorb impermanent loss without adequate compensation.

During a volatile period, arbitrageurs extract value from LPs faster than fees can cover it. Higher fees during those windows compensate LPs for that risk — like surge pricing during rush hour. There is no mechanism in V3 to adapt. The fee is set once and never changes.

The Solution

This hook plugs into Uniswap V4's hook system and adjusts the swap fee on every single swap based on how volatile the pool's price has been recently.

High volatility  →  Fee increases  →  LPs compensated for impermanent loss risk
Low volatility   →  Fee decreases  →  More competitive, more volume attracted

The fee adapts automatically. No governance votes, no manual intervention, no external oracles. Every time someone swaps, the hook reads how much the price just moved, blends that into a running average of recent volatility, and sets the fee for the next swap accordingly — all in a single beforeSwap callback costing ~15,000 gas.


Live Results

Deployed and tested on Sepolia. These are real on-chain transactions, not simulations.

Fee Progression

Swap Direction Amount In Fee Applied Volatility (EWMA) Notes
1 token0 → token1 1 ETH 0.30% (3,000 pips) 0 First swap — no history, default fee
2 token0 → token1 1 ETH 0.05% (500 pips) ~0 Calm — no price movement between swaps
Prime token1 → token0 10 ETH 0.103% (1,032 pips) 5.6e14 Large reversal — spikes the EWMA
3 token0 → token1 1 ETH 1.00% (10,000 pips) 4.66e16 Post-spike — EWMA saturates at maxFee

The fee jumped 20× in a single swap — from the minimum (0.05%) to the maximum (1.00%) — in direct response to the price volatility created by the large reversal.

FeeUpdated event, Swap 3:
  fee:        10000                  ← 1.00% — maxFee applied
  volatility: 46639192007823281      ← 4.66e16, well above MAX_VOLATILITY_REFERENCE (1e16)

Swap event, Swap 3:
  fee: 10000  tick: 8232

How the Spike Propagated

The large reversal (10 ETH, price moved ~14,000 ticks) stored an EWMA of ~4.66e17 in afterSwap. When Swap 3 ran, beforeSwap decayed that value by (1 - α) = 10%, yielding 4.66e16 — still 4.6× above MAX_VOLATILITY_REFERENCE (1e16). The linear fee curve saturated immediately at maxFee = 10,000 pips.

Deployed Contracts (Sepolia)

Contract Address
Hook 0x74Aeaf301c0fdaf0CD0aA101BB350C597c31D0C0
V4 PoolManager 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543
SwapRouter 0xf13D190e9117920c703d79B5F33732e10049b115
Token0 0x298CCf8952abbc1aAd356D3C38f4ed9b373fdA70
Token1 0xc76a7B0C4E362be07046Af9Eda7CEBFbe1647027

Pool config: minFee = 500 (0.05%), maxFee = 10,000 (1.00%), decayFactor = 900, curve = linear


How It Works

The hook uses an EWMA (Exponentially Weighted Moving Average) model to estimate volatility on-chain with no external dependencies.

The EWMA Model

After each swap:
  r   = ln(sqrtPrice_new / sqrtPrice_old)   ← log-return (WAD-scaled via Solady lnWad)
  r²  = r × r                               ← squared return (always positive)
  σ²  = α × r² + (1-α) × σ²_old            ← EWMA update

Fee mapping (linear):
  fee = minFee + (σ² / MAX_VOL_REF) × (maxFee - minFee)
  clamped to [minFee, maxFee]

MAX_VOLATILITY_REFERENCE = 1e16 (WAD-scaled) corresponds to approximately a 10% price swing in a single observation. At or above that level, fee = maxFee.

Hook Lifecycle

  1. afterInitialize — pool existence confirmed; config registered via setPoolConfig
  2. beforeSwap — reads stored EWMA, computes updated value using current pool price, maps to fee, returns it with OVERRIDE_FEE_FLAG
  3. afterSwap — reads post-swap price, updates EWMA and stores it with the new price in one SSTORE

beforeSwap computes but does not write. afterSwap writes. This split is intentional: beforeSwap uses the pre-swap price for fee computation; afterSwap captures the realized post-swap price for the next observation.

Why EWMA and Not Something Else?

Method Gas Cost Storage External Deps Chosen
EWMA ~500 gas 1 slot None
Rolling window ~2,000 gas N slots None
Chainlink oracle ~2,600 gas 0 slots Chainlink feed
Realized variance ~1,000 gas Multiple slots None

Since this code runs on every swap, gas efficiency is the primary constraint.


Architecture

The project follows a strict layered separation of concerns — each directory has exactly one responsibility.

src/
├── core/
│   ├── Constants.sol        Protocol constants (DEFAULT_FEE, MAX_FEE, DECAY_PRECISION)
│   ├── Errors.sol           Centralized custom errors
│   └── Types.sol            Shared data structures (PoolConfig, VolatilityState)
│
├── interfaces/
│   └── IDynamicFeeHook.sol  External contract specification (setPoolConfig, getters, events)
│
├── libraries/
│   ├── FeeCalculator.sol    Pure math: maps EWMA volatility → swap fee (linear + sigmoid)
│   └── VolatilityOracle.sol Pure math: computes EWMA from sqrtPrice observations
│
└── DynamicFeeHook.sol       Concrete implementation — BaseHook + IDynamicFeeHook
Layer Responsibility
core/ Foundational primitives — constants, errors, structs. No logic.
interfaces/ Contract specification. Defines what callers can expect.
libraries/ Pure, stateless math. No storage access. Independently testable.
DynamicFeeHook.sol Wires all layers together. Handles V4 hook callbacks and storage.

Contract Flow

                    ┌─────────────────────────────────┐
                    │         Pool Manager             │
                    │    (Uniswap V4 Singleton)        │
                    └──────┬──────────────┬────────────┘
                           │              │
                    1. beforeSwap    3. afterSwap
                           │              │
                    ┌──────▼──────────────▼────────────┐
                    │       DynamicFeeHook              │
                    │                                   │
                    │  ┌─────────────────────────────┐  │
                    │  │    VolatilityOracle          │  │
                    │  │    - Read last price         │  │
                    │  │    - Compute log-return      │  │
                    │  │    - Update EWMA             │  │
                    │  └─────────────┬───────────────┘  │
                    │                │                   │
                    │  ┌─────────────▼───────────────┐  │
                    │  │    FeeCalculator             │  │
                    │  │    - Map volatility → fee    │  │
                    │  │    - Clamp to [min, max]     │  │
                    │  │    - Return with override    │  │
                    │  └─────────────────────────────┘  │
                    └───────────────────────────────────┘

Storage Design

All per-pool state fits in two storage slots — one for config (set once) and one for volatility state (updated every swap):

VolatilityState (256 bits = 1 slot):
┌──────────────────┬────────────────────────┬───────────────┐
│  ewmaVolatility  │   lastSqrtPriceX96     │ lastTimestamp │
│    (96 bits)     │      (128 bits)        │   (32 bits)   │
└──────────────────┴────────────────────────┴───────────────┘

The slot warmed by beforeSwap's SLOAD is the same slot written by afterSwap's SSTORE — costing ~5,000 gas for the write instead of ~20,000 gas (cold). Without this packing, the hook would cost an extra ~14,400 gas per swap.


Getting Started

Prerequisites

Installation

git clone https://github.com/CodesenSys/V4-DynamicFeeHook.git
cd V4-DynamicFeeHook
forge install

Build

forge build

Test

# Unit + integration tests
forge test

# With verbosity
forge test -vvv

# Fuzz tests (extended runs)
forge test --fuzz-runs 10000

# Invariant tests
forge test --match-contract Invariant --invariant-runs 1000

Testing

Tests mirror the source layer separation — each file covers exactly one concern.

test/
├── utils/
│   ├── BaseTest.sol              Forge test base — deploys V4 infrastructure
│   ├── Deployers.sol             Token and pool deployment helpers
│   └── libraries/EasyPosm.sol   Position manager calldata helpers
│
├── unit/
│   ├── BaseTest.t.sol            Abstract hook test base — mines hook address, initialises pool
│   ├── SetPoolConfig.t.sol       Config validation: happy paths, all revert conditions
│   ├── BeforeSwap.t.sol          Fee computation: first-swap default, dynamic fee, event bounds
│   └── AfterSwap.t.sol           EWMA state: price recording, EWMA updates, pool isolation
│
├── fuzz/
│   └── DynamicFeeHookFuzz.t.sol  Property tests — fee bounds, EWMA overflow, config validity
│
└── invariant/
    └── DynamicFeeHookInvariant.t.sol  Stateful invariants across arbitrary swap sequences

Test Counts

Suite Type Tests
SetPoolConfig Unit 12
AfterSwap Unit 9
BeforeSwap Unit 4
DynamicFeeHookFuzz Fuzz 4 × 256 runs
DynamicFeeHookInvariant Invariant Stateful, 1,000 runs

Key Properties

These hold under all tested conditions:

  • Fee is always within [poolConfig.minFee, poolConfig.maxFee]
  • EWMA is never negative and never overflows uint96
  • Per-pool state is isolated — swaps in Pool A never affect Pool B
  • Config is immutable after the first setPoolConfig call

Gas Benchmarks

Measured with forge snapshot on Solc 0.8.30, EVM Cancun. Verified against live Sepolia traces.

Hook overhead per swap (from live Sepolia traces)

Callback Gas Notes
beforeSwap (cold SLOAD) ~15,900 Includes EWMA compute + fee curve
afterSwap (warm SSTORE) ~13,100 Slot already warm from beforeSwap
Total hook overhead ~29,000 Added on top of base V4 swap cost

Storage packing impact

This hook (packed) Naive (3 slots) Saving
SLOAD in beforeSwap 2,100 gas (cold) 6,300 gas 4,200 gas
SLOAD in afterSwap 100 gas (warm) 300 gas 200 gas
SSTORE in afterSwap ~5,000 gas (dirty) ~15,000 gas ~10,000 gas
Per-swap saving ~14,400 gas

Reproduce locally

forge snapshot                # capture baseline
forge snapshot --check        # fail if any test regresses

The .gas-snapshot file is committed. Any change that increases gas on a tracked test will fail --check.


Deployment

Local (Anvil)

# Terminal 1
anvil

# Terminal 2
forge script script/DeployAnvil.s.sol \
    --rpc-url http://localhost:8545 \
    --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
    --broadcast

Testnet (Sepolia)

cp .env.example .env
# set SEPOLIA_RPC_URL and PRIVATE_KEY

source .env && forge script script/DeploySepolia.s.sol \
    --rpc-url $SEPOLIA_RPC_URL \
    --private-key $PRIVATE_KEY \
    --broadcast -vvvv

Creating a Pool with This Hook

// Step 1 — initialize the pool (DYNAMIC_FEE_FLAG required)
PoolKey memory key = PoolKey({
    currency0: currency0,
    currency1: currency1,
    fee: LPFeeLibrary.DYNAMIC_FEE_FLAG,
    tickSpacing: 60,
    hooks: IHooks(address(hook))
});
poolManager.initialize(key, sqrtPriceX96);

// Step 2 — register fee config (immutable after this call)
hook.setPoolConfig(key, PoolConfig({
    minFee: 500,       // 0.05% — floor fee in calm markets
    maxFee: 10000,     // 1.00% — ceiling fee in volatile markets
    decayFactor: 900,  // EWMA decay (higher = more reactive)
    useSigmoid: false  // linear curve
}));

Configuration Guide

Parameter Type Range Description
minFee uint24 1 – 999,999 Floor fee in pips (100 = 0.01%). Applied when volatility is near zero.
maxFee uint24 1 – 1,000,000 Ceiling fee in pips (10,000 = 1.00%). Applied at maximum volatility.
decayFactor uint32 1 – 999 EWMA decay (out of 1000). Higher = recent swaps weighted more. 900 is a good default.
useSigmoid bool Sigmoid curve flattens at extremes, preventing extreme fee jumps during flash crashes. Linear is simpler to reason about.

Recommended Configurations

Blue-chip pairs (ETH/USDC, WBTC/ETH):

minFee: 100, maxFee: 3000, decayFactor: 900, useSigmoid: false

Volatile pairs (memecoins, new tokens):

minFee: 500, maxFee: 10000, decayFactor: 950, useSigmoid: true

Stablecoin pairs (USDC/USDT, DAI/USDC):

minFee: 10, maxFee: 500, decayFactor: 800, useSigmoid: false

Project Structure

V4-DynamicFeeHook/
├── src/
│   ├── core/
│   │   ├── Constants.sol
│   │   ├── Errors.sol
│   │   └── Types.sol
│   ├── interfaces/
│   │   └── IDynamicFeeHook.sol
│   ├── libraries/
│   │   ├── FeeCalculator.sol
│   │   └── VolatilityOracle.sol
│   └── DynamicFeeHook.sol
├── test/
│   ├── utils/
│   ├── unit/
│   ├── fuzz/
│   └── invariant/
├── script/
│   ├── 00_DeployHook.s.sol
│   ├── 01_CreatePoolAndAddLiquidity.s.sol
│   ├── 02_AddLiquidity.s.sol
│   ├── 03_Swap.s.sol
│   ├── DeployAnvil.s.sol
│   └── DeploySepolia.s.sol
├── .gas-snapshot
├── foundry.toml
└── README.md

Technical References

Uniswap V4:

Hook Ecosystem:

Dependencies:

Security:


Roadmap

  • Layered source architecture (core / interfaces / libraries)
  • EWMA volatility model (VolatilityOracle)
  • Linear and sigmoid fee curves (FeeCalculator)
  • Hook implementation (DynamicFeeHook)
  • NatSpec documentation throughout
  • Unit tests — SetPoolConfig, BeforeSwap, AfterSwap
  • Fuzz tests — fee bounds, EWMA overflow, config validity
  • Invariant tests
  • Gas snapshot (forge snapshot)
  • Deployment scripts (Anvil + Sepolia)
  • Live deployment on Sepolia with verified fee changes
  • GitHub Actions CI
  • Formal security review

License

MIT — see LICENSE for details.


Built by Haseeb Ali (CodesenSys)
Solidity · Foundry · Solady · Uniswap V4