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 Works • Live Results • Architecture • Getting Started • Testing • Gas Benchmarks • Deployment
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.
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.
Deployed and tested on Sepolia. These are real on-chain transactions, not simulations.
| 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
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.
| 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
The hook uses an EWMA (Exponentially Weighted Moving Average) model to estimate volatility on-chain with no external dependencies.
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.
afterInitialize— pool existence confirmed; config registered viasetPoolConfigbeforeSwap— reads stored EWMA, computes updated value using current pool price, maps to fee, returns it withOVERRIDE_FEE_FLAGafterSwap— 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.
| 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.
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. |
┌─────────────────────────────────┐
│ 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 │ │
│ └─────────────────────────────┘ │
└───────────────────────────────────┘
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.
- Foundry (stable)
- Git
git clone https://github.com/CodesenSys/V4-DynamicFeeHook.git
cd V4-DynamicFeeHook
forge installforge build# 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 1000Tests 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
| Suite | Type | Tests |
|---|---|---|
SetPoolConfig |
Unit | 12 |
AfterSwap |
Unit | 9 |
BeforeSwap |
Unit | 4 |
DynamicFeeHookFuzz |
Fuzz | 4 × 256 runs |
DynamicFeeHookInvariant |
Invariant | Stateful, 1,000 runs |
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
setPoolConfigcall
Measured with forge snapshot on Solc 0.8.30, EVM Cancun. Verified against 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 |
| 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 |
forge snapshot # capture baseline
forge snapshot --check # fail if any test regressesThe .gas-snapshot file is committed. Any change that increases gas on a tracked test will fail --check.
# Terminal 1
anvil
# Terminal 2
forge script script/DeployAnvil.s.sol \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--broadcastcp .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// 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
}));| 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. |
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
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
Uniswap V4:
Hook Ecosystem:
Dependencies:
- Solady FixedPointMathLib —
lnWad,mulWad,mulDiv
Security:
- 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
MIT — see LICENSE for details.
Built by Haseeb Ali (CodesenSys)
Solidity · Foundry · Solady · Uniswap V4