UniswapV4 的创新重点不在于改进多少 AMM 技术,更著重于扩展生态系统。具体来说,这次的创新包括以下几个关键功能:% E9 Q9 p3 ~" `$ q6 x; [
- Flash Accounting
- Singleton Contract
- Hooks Architecture$ u& B& w* d- A& P: C4 `! m
7 J" u/ x- a7 Q
Flash Accounting
Double Entry Bookkeeping
UniswapV4 采用了类似于複式簿记(Double Entry Bookkeeping)的记录方式,来跟踪每一个操作对应的 Token 馀额增减变化。这种複式簿记的记录方式要求每一笔交易都必须同时在多个帐户中进行记录,并确保这些帐户之间的资产价值保持平衡。举个例子,假设使用者以 100 TokenA 向 Pool 交换 50 TokenB ,那麽在帐本中记录会是如下:, `, Y- @& h; N' o4 c
- USER: TokenA 减少 100 单位(-100),而 TokenB 增加 50 单位(+50)。
- POOL: TokenA 增加 100 单位(+100),而 TokenB 减少 50单位(-50)。7 h' V+ O0 _$ O7 R# ?, C q4 ], B
Token Delta 相关操作
在 UniswapV4 中,主要操作都会採用这种记帐方式,并在程式码中使用一个名为 lockState.currencyDelta[currency] 的 Storage Variable 来记录 Token 馀额的变化量。这个变化量的数值如果为正数,表示 Token 在池中预期增加的数量,反之则表示 Token 在池中预期减少的数量。另一个角度来看,如果数值为正,代表池中缺少的 Token 数量(预计要收到的 Token 数量),而数值为负则代表这个池中多馀的 Token 数量(预计使用者要提领的 Token 数量)。以下列出了各种操作对 Token 变化量(TokenDelta)的影响:
- modifyPosition:表示执行 Add/Remove liquidity 的操作。对于 Add liquidity,使用加法更新 Token 变化量(表示预计添加到池中的 TokenA)。对于 Remove liquidity,则使用减法更新 Token 变化量(表示预计从池中提取 TokenB)。
- swap:表示执行 Swap 操作。以 Swap TokenA 到 TokenB 为例,使用加法更新 TokenADelta,而使用减法更新 TokenBDelta。
- settle:伴随将 Token 传送到 Pool 的操作。Pool 会计算前后 Token 的增加量,使用减法更新 TokenDelta。若 Pool 刚好收到预期中的 Token 数量,则这裡的减法更新刚好将 TokenDelta 归零。
- take:伴随将 Token 从 Pool 中提领的操作。Pool 会使用加法更新 TokenDelta,表示 Token 已经从这个 Pool 中移出。
- mint:更新 TokenDelta 的行为与 "take" 相似,只是 mint 并不实际从池中提领 Token。取而代之发行对应的 ERC1155 Token 作为提领的证明,而 token 仍然保留在池中。之后,用户可以通过销毁 ERC1155 Token 来取回 Pool 中的 Token。猜测其目的有两点:1.节省 ERC20 Token 转移的 gas 成本(contract call +少一次 storage write),未来利用 ERC1155 token burn 的方式更新 TokenDelta 来供交易使用。2. 将流动性保留在 Pool 中,维持流动性深度让使用者有更好的 Swap Token 体验。
- donate:宣告将 Token 捐赠给 Pool,但实际上仍需要使用 "settle" 将 Token 送入 Pool 中。因此,在这裡使用加法更新 Token 变化量。/ |7 E; x& \& ?+ u
Token Delta Example7 e f- |& i+ q1 u, z
以下我们用一个简单的例子来说明实际是如何去更新 TokenDelta 。假设今天我们将 100 个 TokenA 兑换为 50 个 TokenB:3 v) q/ r' ], z6 V+ y; G; ]) {
. H) }$ e( [. H5 C+ T
- 交易开始前 TokenADelta 和 TokenBDelta 都为 0。
- swap:计算 Pool 需要接收多少TokenA,以及使用者将收到多少TokenB。此时,TokenADelta = 100,TokenBDelta = -50。
- settle:将 100 个 TokenA 送入Pool ,并更新 TokenADelta = 100–100 = 0。
- take:将 50 个 TokenB 从 Pool 转移到使用者帐户,并更新 TokenBDelta = -50 + 50 = 0。
- 交易结束后 TokenADelta 和 TokenBDelta 都为 0。3 G C; y2 {6 p/ O" w0 Q7 D' _
EIP-1153: Transient storage opcodes
之前提到 UniswapV4 利用 Storage Variable 来记录 TokenDelta,但在合约内部,Storage Variable 的读写是相当高成本的。这时候就要提到另一个 Uniswap 所推出来的 EIP:EIP1153 — Transient Storage Opcodes。& Q i4 \( ?6 q. m
UniswapV4 计划使用 EIP1153 所提供的 TSTORE 和 TLOAD 这两个 OP Code 来更新 TokenDelta。採用 Transient Storage Opcodes 的 Storage Variable 会在 Transaction 结束后被丢弃(类似 Memory Variable),从而不必写入硬碟,进而降低 Gas 费用。
EIP1153 已被确定会被包含在下次的坎昆升级,同时 UniswapV4 也指出将会在坎昆升级之后上线 UniswapV4。: [4 U2 K& ]- V5 n
Flash Accounting — Lock5 J. r' u" W( _8 A- e9 s% O' K/ e
UniswapV4 引入了 lock 机制,这意味著在进行 Pool 操作之前,必须首先调用 PoolManager.lock() 以获取一个锁(Lock)。在 lock() 的执行结束前,会检查 TokenDelta 的数值是否为 0,否则将引发 revert。当调用 PoolManager.lock() 并成功获得锁之后,将会呼叫 msg.sender的 lockAcquired() 函数。在 lockAcquired() 函数中,才执行与 Pool 相关的操作(例如 swap、modifyPosition 等操作)。# G6 J, p, k5 d
以下以图示为例来说明这个过程。当使用者需要进行 Token Swap 操作时,必须呼叫一个具有 lockAcquired() 函数的 Smart Contract(这裡称为回调合约,CallBack Contract)。回调合约将首先呼叫 PoolManager.lock(),然后 PoolManager 会呼叫回调合约的 lockAcquired() 函数。在 lockAcquired() 函数中,定义了与 Pool 操作相关的逻辑,例如 swap、settle 以及 take 等操作。最后,在整个 lock() 即将结束时,PoolManager 会检查与这次操作有关的 TokenDelta 是否已经全部重置为 0,以确保 Pool 中的资产保持平衡。% y6 x# r: i& Y# k; d8 @. q; ^
Singleton Contract
Singleton Contract 意味著 UniswapV4 已经弃用了以往的 Factory-Pool 模式。每个 Pool 不再是一个独立的 Smart Contract,而是所有 Pool 共用同一个单例(singleton)合约。这种设计与 Flash Accounting 机制结合,只需要更新必要的 Storage Variable,进一步降低了操作的複杂性和成本。
以下以图示为例,以 UniswapV3 为例,将 ETH 兑换为 DAI 至少需要执行四次 Token 转移( Storage 写入操作)。这包括对 USDC、USDT 和 DAI Token 的多次变化记录。然而,透过 UniswapV4 的改进,搭配 Flash Accounting 机制,只需要一次 Token 转移(将 DAI 由 Pool 转移到使用者),这大幅降低了操作的次数和成本。$ b) V: v, {# |- Q# ~0 i
" _" ~0 D/ `7 c( f. V+ e; X
Hooks Architecture
UniswapV4 这次的更新中,最引人注目的要属 Hooks Architecture。这项更新将围绕在 Pool 可利用性上提供了极大的灵活性。Hooks 是指在对 Pool 执行特定操作时,会额外调用 Hooks Contract 来执行额外的动作。而这些动作可以分为不同类别,包括initialize(create pool)、modifyPosition(add/remove liquidity)、swap和 donate,每个类别都有执行前和执行后的动作:
- beforeInitialize / afterInitialize
- beforeModifyPosition / afterModifyPosition
- beforeSwap / afterSwap
- beforeDonate / afterDonate1 Q( n% H& R6 D& e/ t: S1 m u
Hook Example — Limit Order Hook
接下来会用限价订单(Limit Order)的例子来说明 Hooks 的实际操作流程。在开始之前先简单解释在 UniswapV4 中 实现限价订单的原理。" v, A7 O1 t+ s; N4 s+ r1 w" z
UniswapV4 Limit Order 机制
UniswapV4 中实现限价订单的原理是通过将流动性添加(Add Liquidity)到特定价格区间,然后如果该区间的流动性被交换,则执行移除流动性(Remove Liquidity)操作来达成。, \6 a' b. M% p+ Q
举个例子,假设我们在 ETH 的价格范围为 1900–2000 之间添加了流动性,然后当 ETH 价格从 1800 上涨到 2100 时。此时,我们之前在 1900–2000 价格区间内添加的 ETH 流动性已经全部被交换成 USDC(假设在 ETH-USDC Pool )。此刻移除了流动性就可以获得类似以当前价格 1900–2000 执行 ETH 市价订单的效果。
Limit Order Hook Contract
这个范例是来自UniswapV4 的 GitHub 提供。在这个范例中,Limit Order Hook 合约提供了两个 Hooks,分别是 afterInitialize 和 afterSwap。其中 afterInitialize 用于记录建立 Pool 时的价格区间(tick),以便在有人做 swap 之后确定哪些限价订单已经被匹配。. ~6 ]6 o7 a O2 B
Place Order
当使用者需要下单时,Hook 合约会根据使用者指定的价格区间和数量执行添加流动性的操作。在限价订单的 Hook 合约中,你可以看到有 place() 函数。主要的逻辑是在获得锁定(Lock)后调用 lockAcquiredPlace() 函数来执行添加流动性的操作,这部分等同于下单一个限价订单。
z* \# w4 T4 b& ?2 C! E
afterSwap Hook
使用者完成在这个 Pool 内的 Swap Token 后,Pool 会调用Hook 合约的 afterSwap() 函数。afterSwap 的主要逻辑是将之前价格区间到目前价格区间之间已经执行过的下单操作进行移除流动性的动作。这样的行为等同于订单已经被执行(order filled)。/ M0 M1 `% T- ?# O! m x/ L
Limit Order Flow% L3 M, `5 m- S% H2 a/ Q8 {1 U
以下是限价订单成交的流程示意图:
- 订单下单者将订单发送给 Hook 合约。
- Hook 合约根据订单信息执行添加流动性操作。
- 一般用户在 Pool 中进行 Swap Token 操作。
- Swap Token 操作完成后,Pool 会调用 Hook 合约的 afterSwap() 函数。
- Hook 合约根据 Swap Token 的价格区间变化,执行已成交限价订单的移除流动性操作。5 f/ I% n0 l& ^% u7 } T) ]
Hook: Other features0 M+ w4 [' T* w6 B: R
Hooks 还有几个笔者在研究时觉得有趣的点,觉得值得提出来跟大家分享。8 O9 e5 ]$ }% e1 x; c
Hooks Contract Address Bit% o+ r2 y' o4 E
判断是否需要执行 before/after 特定操作是由 Hook 合约地址的最左边的 1 个 byte 来决定的。1 个 byte 等于 8 个位元(bits),正好对应到 8 个额外的动作。Pool 会检查该动作的位元是否为 1,以确定是否应该调用 Hook 合约的相应 hook 函数。这同时也意味著 Hook 合约的地址需要按照特定的方式设计,并且不能随意选择合约地址作为 Hook 合约。这种设计主要目的是为了降低 Gas 的消耗,将成本转移到合约部署上,以实现更高效的操作。(PS: 实务上可以使用不同 CREATE2 salt 来暴力计算出符合条件的 contract address)& x H% I8 Z* G9 P
4 E4 o- m& P. E
Dynamic Fee
除了能够在每个动作的前后执行额外的操作外,Hooks还支持动态手续费(dynamic fee)的实现。在建立 Pool 时,可以指定是否启用动态手续费。如果启用了动态手续费,在 Swap Token 时会调用 Hook 合约的 getFee() 函数。Hook合约可以根据当时的 Pool 状态来决定应该收取多少手续费。这种设计使得手续费的计算可以根据实际情况进行调整,提高了系统的灵活性。, X# ?1 a2 [0 Z, N+ N) G
Pool Creation) g7 s% a8 E) Y. ]
每个 Pool 在建立时需要决定 Hook 合约,之后不能更改(不过不同的 Pool 可以共用相同的 Hook 合约)。这主要是因为 Hooks 被视为组成 PoolKey 的一部分,PoolManager 使用 PoolKey 来识别对哪个 Pool 执行操作。即使资产相同,但如果 Hook 合约不同,则这将被视为不同的 Pool。这种设计确保了不同 Pool 的状态和操作可以被独立管理,并确保了 Pool 的一致性。但同时也因为 Pool 数量增多而增加路由(routing)的複杂性(也许 UniswapX 就是设计来解决这个问题的方式之一)。 k# l+ y1 Y4 j! j; r# H9 Q+ C" E- U
TL;DR
- Flash Accounting 用来追踪每个 Token 的数量变化,确保在完成交易后所有变化都被归零。为了节省 Gas 费用,Flash Accounting 使用了 EIP1153 提供的特殊储存方式。
- Singleton Contract 的设计有助于减少 Gas 消耗,因为它避免了对多个储存变数的更新。
- Hooks 架构则提供了额外的操作,分为 “预执行” 和 “后执行” 阶段。这让每个 Pool 操作可以更为弹性,但也使得 Pool 的 routing 变得更加複杂。
最后感谢 Anton Cheng 以及 Ping Chen 帮忙 Review 文章和给出宝贵的意见!