Uniswap V4 Hooks 指南

hook 的权限不写在任何注册表里,它编码在合约地址的低 14 位上,所以部署之前得先把地址挖出来。跟着 v4-core 读一遍:14 个权限位各占哪一位、unlock 那段账怎么结平、动态费率为什么是 fee 字段的最高位。

预计
9 分钟

把一个 hook 挂到 v4 的池子上,你不需要注册、不需要申请、也不需要在任何地方登记它实现了哪几个回调。PoolManager 判断该不该回调你,办法是看你的地址

权限就编码在 hook 合约地址的低 14 位里。想让 beforeSwap 被调用,你的地址第 7 位就得是 1;不想让 afterDonate 被调用,第 4 位就得是 0。地址不对,池子创建时直接 revert。

这一条听起来像个奇技淫巧,但它是 v4 整套设计里最省事的一步:PoolManager 每次要决定「这个池子有没有装 beforeSwap」时,做的是一次位与运算,不是一次外部调用,也不是一次存储读取。

14 个权限位,从第 13 位数到第 0 位

Hooks.sol 顶上把 14 个标志位一次列全(1 << n 就是第 n 位):

常量 十六进制 什么时候被调
BEFORE_INITIALIZE_FLAG 13 0x2000 池子初始化前
AFTER_INITIALIZE_FLAG 12 0x1000 池子初始化后
BEFORE_ADD_LIQUIDITY_FLAG 11 0x0800 加流动性前
AFTER_ADD_LIQUIDITY_FLAG 10 0x0400 加流动性后
BEFORE_REMOVE_LIQUIDITY_FLAG 9 0x0200 撤流动性前
AFTER_REMOVE_LIQUIDITY_FLAG 8 0x0100 撤流动性后
BEFORE_SWAP_FLAG 7 0x0080 兑换前
AFTER_SWAP_FLAG 6 0x0040 兑换后
BEFORE_DONATE_FLAG 5 0x0020 捐赠前
AFTER_DONATE_FLAG 4 0x0010 捐赠后
BEFORE_SWAP_RETURNS_DELTA_FLAG 3 0x0008 允许 beforeSwap 改动账目
AFTER_SWAP_RETURNS_DELTA_FLAG 2 0x0004 允许 afterSwap 改动账目
AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG 1 0x0002 允许 afterAddLiquidity 改动账目
AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG 0 0x0001 允许 afterRemoveLiquidity 改动账目

前十个是回调点,成对出现:初始化、加流动性、撤流动性、兑换、捐赠,各有前后两个时机。后四个不是新的回调时机,而是给已有回调加的一项额外授权 —— 允许它在返回值里修改这笔交易的账目,把一部分金额划给自己或者退给用户。

分成两类是有道理的。一个只想记录成交价的 hook 和一个想从每笔兑换里抽走一点的 hook,在链上的危险程度完全不同,v4 让这个区别直接体现在地址上:看到低 4 位全是 0,就知道这个 hook 动不了钱。

对应到接口,能改账目的那几个回调返回值也更宽:

v4-core · IHooks.sol · 返回值里带 delta 的几个(节选)
// 只能放行或拦截:返回自己的 selector
function beforeInitialize(address sender, PoolKey calldata key, uint160 sqrtPriceX96)
external returns (bytes4);
// 除了 selector,还能改这笔兑换的账目,以及覆盖本次费率
function beforeSwap(address sender, PoolKey calldata key, IPoolManager.SwapParams calldata params, bytes calldata hookData)
external returns (bytes4, BeforeSwapDelta, uint24);
// 能在兑换完成后再从结果里划走一块
function afterSwap(address sender, PoolKey calldata key, IPoolManager.SwapParams calldata params, BalanceDelta delta, bytes calldata hookData)
external returns (bytes4, int128);

第 7 行末尾那个 uint24 就是动态费率的出口,后面单独讲。返回 BeforeSwapDeltaint128 想要生效,地址上对应的 returnDelta 位必须是 1,否则 PoolManager 不认。

hook 是被 PoolManager 回调的,不是被用户直接调用的 —— 用户始终只跟 PoolManager 打交道:

一次兑换里 hook 的位置:用户这一列只连着 PoolManager,由 PoolManager 先回调 beforeSwap、再执行池内兑换、然后回调 afterSwap,最后结算 delta 把结果返回给用户 一次兑换里 hook 的位置:用户这一列只连着 PoolManager,由 PoolManager 先回调 beforeSwap、再执行池内兑换、然后回调 afterSwap,最后结算 delta 把结果返回给用户

挖地址的期望次数是 16384,和你开了几个权限无关

地址靠 CREATE2 决定:keccak256(0xff, deployer, salt, initCodeHash) 取低 20 字节。initCodeHash 由你的合约字节码定死,deployer 固定,能调的只有 salt。于是部署 hook 变成一件很土的事 —— 换着 salt 反复算,直到算出来的地址低 14 位正好等于你要的那个组合。

关键在「正好等于」。校验时 v4 逐位比对:你实现了 beforeSwap,第 7 位必须是 1;你实现 afterDonate,第 4 位就必须是 0。没开的权限位也要碰对,所以要撞的是完整的 14 位,期望次数是 2¹⁴ = 16384 次,跟你开一个权限还是开八个权限没有关系。

(顺带一提,这也意味着「多开一个权限会让挖矿明显变慢」是个误解。真正让挖矿变慢的是你改了合约字节码 —— initCodeHash 一变,之前挖到的 salt 全部作废,得重来一遍。所以 hook 的构造参数最好别塞进 initcode。)

16384 次 keccak 在链下是毫秒级的事,麻烦的不是算力,是流程:合约代码定稿之后才能挖,挖完拿到的 salt 要一路带到部署脚本里,任何一次「再改一行」都要重新走一遍。

池子不再是合约,所有池共用一个 PoolManager

v2 里每个交易对是一个独立部署的 UniswapV2Pair 合约(Uniswap v2 学习里那套 CREATE2 加 pairFor 就是为了算出它的地址)。v4 把这件事整个取消了:只有一个 PoolManager,池子退化成它内部的一条记录,由一个结构体唯一标识:

v4-core · types/PoolKey.sol
struct PoolKey {
/// @notice The lower currency of the pool, sorted numerically
Currency currency0;
/// @notice The higher currency of the pool, sorted numerically
Currency currency1;
/// @notice The pool LP fee, capped at 1_000_000. If the highest bit is 1, the pool has a dynamic fee and must be exactly equal to 0x800000
uint24 fee;
/// @notice Ticks that involve positions must be a multiple of tick spacing
int24 tickSpacing;
/// @notice The hooks of the pool
IHooks hooks;
}

hooksPoolKey 的一个字段,这一点值得停一下:hook 是池子身份的一部分。同样是 USDC/ETH、同样的费率和 tickSpacing,挂不同的 hook 就是两个不同的池子,流动性不互通。这是 hooks 换来灵活性所付的第一笔账 —— 同一个交易对可以被切成任意多个互不相干的池子。

开一个新池不再需要部署合约,只是往 PoolManager 里写一条记录。这确实比部署一整个合约便宜得多,不过具体便宜多少取决于链和当时的 gas 价,我没有找到 Uniswap 官方公布过一个可引用的数字,所以这里不写倍数。

unlock 打开一段账,结束时所有欠款必须归零

单例架构真正的收益不在开池成本,在于钱可以不动。v2 里一次三跳兑换要发生三次真实的代币转账;v4 里三跳都在同一个 PoolManager 内部记账,只在最后结算一次净额。

入口是 unlock

v4-core · PoolManager.sol · unlock
function unlock(bytes calldata data) external override returns (bytes memory result) {
if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();
Lock.unlock();
result = IUnlockCallback(msg.sender).unlockCallback(data);
if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
Lock.lock();
}

五行代码就是整套闪电记账。第 4 行把控制权交回给调用者的 unlockCallback,你在里面爱做多少次 swapmodifyLiquiditydonate 都行,每一次只是把你欠 PoolManager 的(或它欠你的)金额累加到一个 delta 上。第 5 行是唯一的硬约束:回调结束时,非零 delta 的数量必须是 0,否则整笔 revert。

结清 delta 的动作是 settle(你欠它,把钱转进去)和 take(它欠你,把钱取出来),配合 sync 记录转账前的余额。NonzeroDeltaCount 只是一个计数器 —— 它不关心你欠了什么、欠了多少,只关心还剩几笔没结平。

锁和计数器都放在瞬态存储里(EIP-1153 的 tstore / tload,Cancun 升级引入),交易结束自动清零,不写永久存储也不必手动重置。这是 v4 能把「一笔交易内的临时账」做得便宜的前提。

onlyWhenUnlocked 修饰符挂在 swapmodifyLiquidity 这些函数上:没有先 unlock 就直接调它们会 revert。所以 v4 没有「直接调用」这回事,任何操作都必须包在一次 unlock 里,这也是它和 v2 那种「先转账再调用」的约定最大的形态差别。

ERC-6909 让钱可以留在 PoolManager 里

PoolManager 的继承列表里有 ERC6909Claims。ERC-6909 是一个多代币标准(同一个合约里管很多种 token 的余额),v4 用它来发「凭据」:结算时你可以不把真实代币 take 出去,而是 mint 一份等额的 6909 余额留在 PoolManager 里;下次要用,burn 掉就行。

对高频参与者,这省掉的是一进一出两笔 ERC-20 转账。对做市商和聚合器来说,资金常驻 PoolManager 内部、只在需要时才提出去,是比每笔都真实转账便宜得多的做法。

动态费率是 fee 字段最高位上的一个标记

PoolKey.feeuint24,正常情况下直接存费率(上限 1_000_000,也就是 100%)。想要动态费率,这个字段必须恰好等于 0x800000 —— 源码注释写得很直白:最高位是 1 就表示动态费,而且必须正好是这个值,不能在里面顺便塞一个默认费率。

费率从哪来?两条路:hook 在 beforeSwap 的第三个返回值 uint24 里给出本次兑换的费率,或者调 PoolManager.updateDynamicLPFee 改掉这个池子的当前费率。前者按笔定价,后者改的是持续生效的值。

这就是「按波动率调费率」「对不同地址报不同价」这类玩法的落点。要注意它同时也是一个信任面:一个装了动态费 hook 的池子,理论上可以在你这笔交易上把费率调到很高,beforeSwap 返回什么用户在签名时是看不见的。

这套设计的代价

hook 在关键路径上,它挂了池子就挂了。 beforeSwap revert,这笔兑换就 revert;hook 里写了一个死循环或者一段极贵的逻辑,池子就变得没法用。v4 没有给 hook 加 gas 上限,也没有「hook 失败就跳过」的降级路径 —— 装了什么 hook,就要承担它的全部行为。

流动性按 hook 切碎。 hooks 进了 PoolKey,意味着同一个交易对上每多一个 hook 就多一个独立的池子。路由要在更多池子之间找路,每个池子的深度都更浅。v2 那种「一个交易对一个池子」的简单世界没有了。

权限锁死在地址里,改不了。 想给已经上线的 hook 加一个 afterSwap,做不到 —— 那要求一个不同的地址,也就是一个新合约、新的池子、以及把流动性迁过去。可升级代理能换实现,但换不了地址上那 14 位,所以能加的逻辑始终被最初挖的那个地址框着。

动态费率和 returnDelta 是真实的信任让渡。 这两类权限让 hook 能改动本次交易的钱。地址低位是公开的,任何人都能看出一个 hook 有没有这些权限 —— 但「有权限」和「会不会滥用」是两回事,后者只能靠读 hook 的源码。

生态数据变化很快,本文不引用。 原文里那些交易量、TVL、APY、活跃 hook 数量的数字我没有找到可追溯的出处,已经全部删掉。想看真实的 v4 hook 实现,这个站上有一篇拆得比较细的:Bunni DEX 架构设计与代码实现深度技术分析