Uniswap V4 Hooks 指南
hook 的权限不写在任何注册表里,它编码在合约地址的低 14 位上,所以部署之前得先把地址挖出来。跟着 v4-core 读一遍:14 个权限位各占哪一位、unlock 那段账怎么结平、动态费率为什么是 fee 字段的最高位。
把一个 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 动不了钱。
对应到接口,能改账目的那几个回调返回值也更宽:
// 只能放行或拦截:返回自己的 selectorfunction 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 就是动态费率的出口,后面单独讲。返回 BeforeSwapDelta 或 int128 想要生效,地址上对应的 returnDelta 位必须是 1,否则 PoolManager 不认。
hook 是被 PoolManager 回调的,不是被用户直接调用的 —— 用户始终只跟 PoolManager 打交道:
挖地址的期望次数是 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,池子退化成它内部的一条记录,由一个结构体唯一标识:
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;}hooks 是 PoolKey 的一个字段,这一点值得停一下:hook 是池子身份的一部分。同样是 USDC/ETH、同样的费率和 tickSpacing,挂不同的 hook 就是两个不同的池子,流动性不互通。这是 hooks 换来灵活性所付的第一笔账 —— 同一个交易对可以被切成任意多个互不相干的池子。
开一个新池不再需要部署合约,只是往 PoolManager 里写一条记录。这确实比部署一整个合约便宜得多,不过具体便宜多少取决于链和当时的 gas 价,我没有找到 Uniswap 官方公布过一个可引用的数字,所以这里不写倍数。
unlock 打开一段账,结束时所有欠款必须归零
单例架构真正的收益不在开池成本,在于钱可以不动。v2 里一次三跳兑换要发生三次真实的代币转账;v4 里三跳都在同一个 PoolManager 内部记账,只在最后结算一次净额。
入口是 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,你在里面爱做多少次 swap、modifyLiquidity、donate 都行,每一次只是把你欠 PoolManager 的(或它欠你的)金额累加到一个 delta 上。第 5 行是唯一的硬约束:回调结束时,非零 delta 的数量必须是 0,否则整笔 revert。
结清 delta 的动作是 settle(你欠它,把钱转进去)和 take(它欠你,把钱取出来),配合 sync 记录转账前的余额。NonzeroDeltaCount 只是一个计数器 —— 它不关心你欠了什么、欠了多少,只关心还剩几笔没结平。
锁和计数器都放在瞬态存储里(EIP-1153 的 tstore / tload,Cancun 升级引入),交易结束自动清零,不写永久存储也不必手动重置。这是 v4 能把「一笔交易内的临时账」做得便宜的前提。
onlyWhenUnlocked 修饰符挂在 swap、modifyLiquidity 这些函数上:没有先 unlock 就直接调它们会 revert。所以 v4 没有「直接调用」这回事,任何操作都必须包在一次 unlock 里,这也是它和 v2 那种「先转账再调用」的约定最大的形态差别。
ERC-6909 让钱可以留在 PoolManager 里
PoolManager 的继承列表里有 ERC6909Claims。ERC-6909 是一个多代币标准(同一个合约里管很多种 token 的余额),v4 用它来发「凭据」:结算时你可以不把真实代币 take 出去,而是 mint 一份等额的 6909 余额留在 PoolManager 里;下次要用,burn 掉就行。
对高频参与者,这省掉的是一进一出两笔 ERC-20 转账。对做市商和聚合器来说,资金常驻 PoolManager 内部、只在需要时才提出去,是比每笔都真实转账便宜得多的做法。
动态费率是 fee 字段最高位上的一个标记
PoolKey.fee 是 uint24,正常情况下直接存费率(上限 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 架构设计与代码实现深度技术分析。