本文将结合其草案代码以及白皮书,对 Uniswap v4 进行相应的分析。
撰文:Eocene Research
6 月 13 日, Uniswap Labs 发布了博客,宣布推出了 Uniswap V4 的草案代码,包括 Uniswap v4 核心和周边库的开源早期版本以及技术白皮书的草案。Uniswap V4 出现了许多新的特性,本文将结合其草案代码以及白皮书进行相应的分析。
在 Uniswap V4 中引入了 hooks,可以由池创建者进行定制化的功能;同时放弃了原来的通过工厂合约建立流动性池的方式,所有池都在一个合约中;使用 Flash accounting 方式节省 Gas;支持原生的 ETH;同时还有一些其他的新特性。
Uniswap 的 V2 和 V3 版本都是通过工厂合约创建新的流动性池,每一个流动性池都是一个单独的合约,因此创建新的流动性池也就意味着会创建一个新的合约,这样会耗费大量的 Gas;而在 V4 版本中则是使用了 Singleton 模式,所有的流动性池都在一个合约中,创建新的流动性池不再需要创建合约,流动性池数据都保存在合约的映射中,通过 poolId 指向 pool.State,poolId 则由 PoolKey 进行哈希并转换成 uint 得到,而 pool.State 则存储 pool 的数据,这样做大大降低了创建新流动性池的 Gas。
然后我们查看代码,分析 V4 的 PoolManager 合约如何创建新的流动性池。
在 V4 中通过 PoolManager 合约中的 initialize 函数创建新的流动性池,需要传入 PoolKey 结构体以及价格,其中的 PoolKey 结构体如下:
PoolKey 中包括了两种代币的地址,以及交换的费用,高位的 4 bit 决定是否在 hook 中收费,然后是 tickSpacing 以及 hook 的地址。然后看 initialize 函数:
函数会校验先校验 fee 和 tickSpacing 的范围是否正确,然后使用 isValidHookAddress 函数校验 hook 地址是否有效:
如果 hook 地址是 0 地址的话则 fee 必须为静态费用,且不在 hook 中收费才能通过校验;不是 0 地址的话则会检查是否启用 hook 功能(将 hook 地址转换为 uint160 整数,并和最小的 FLAG 进行比较,大于则表示启用 hook 功能),或者 fee 满足动态费用或者在 hook 中启用 swapfee 以及 witrhdraw,四个条件满足一个即可通过校验。
接下来,如果 hook 设置了在初始化前调用的话则会去调用 hook 合约的 beforeInitialize 函数,执行 hook 中设定的逻辑。
然后将传入的 PoolKey 转换为 bytes32 类型的 poolId,并通过 protocolFeeController 合约以及 hook 合约获取各种费用的值。
然后调用 initialize 对 pool 的 State 进行初始化:
State 结构体中保存着流动性池的信息,其中初始化的各个参数在 Slot0 结构体中:
最后,判断 hook 是否设置了在初始化后调用,是的话则会去调用 hook 合约的 afterInitialize 函数,执行其设定的逻辑。
可以看到在 V4 中新建流动性池不会再使用 Create2 去新创建合约,取而代之的是将 pool 的信息保存在 pools 这个 mapping 中,这样做大大的节省了新建流动性池操作的 Gas,所有的流动性池的信息都在 PoolManager 合约中。
在 Uniswap V4 中引入了 hook 的概念,可以在流动性池的调用的生命周期内某些指定点执行一些自定义的逻辑,hook 功能增加了流动性池的灵活性,可以执行更多的富有创造力的功能。
Uniswap V4 目前支持在 8 个特定的位置进行 hook 回调:
官方在白皮书中给出的一个在 Swap 时的 hook 流程图,可以看到在 Swap 前后都会进行 flag 判断,决定是否执行 hook:
hook 合约的地址决定会在哪些位置进行回调,如果 hook 合约地址的前两位为 0x01,即 0000 0001,则在 afterDonate 处进行回调,如果是 0x90,即 1001 0000,则会在 beforeInitialize 以及 afterModifyPosition 位置进行回调。在 PoolManager 合约中一系列函数都存在 hook 调用的判断,如在新建流动性池的函数 initialize 中,就存在 shouldCallBeforeInitialize 以及 shouldCallAfterInitialize 判断:
在官方的 v4-periphery 项目中,在 example 目录下存在几个官方编写的 hook 案例合约,包括了:
编写自定义 hook 合约一般来说是继承 v4-periphery 中的 BaseHook 合约,然后去重写对应的逻辑,在 BaseHook 合约中 beforeInitialize 等函数都是直接 revert ,我们需要使用到对应位置的 hook 函数则重写即可,同时 BaseHook 合约提供了修饰器,可以根据实际情况来限定 hook 函数的调用者:
在 Uniswap V4 中一个新的设计是 Flash accounting。在 Uniswap 的早期版本中,像 swap 或者 addLiquidity 等操作都是以代币的转移结束,而在 V4 中,每一个操作会更新内部的一个净余额(delta),在所有操作结束时会校验该值是否为 0,必须保证该值为 0 才能交易成功。当 Flash accounting 和 Singleton 结合时,可以大大简化多跳交易。在 PoolManager 合约中新增了 take 和 settle 函数,分别用于向池中借出、存入资金,通过调用这两个函数,保证调用结束时不欠 PoolManager 合约或调用者任何代币。
下面以官方的 foundry 测试合约中测试 donate 函数为例进行分析,大概的流程如下:
再来看代码,donateRouter 合约中的 donate 函数会先将使用的参数进行打包,然后再调用 PoolManager 合约的 lock 函数,并将打包的参数传入,最后流程结束时,如果有多余的 ETH 会被退回给调用者:
然后在 PoolManager 的 lock 函数中会先将调用者压入到 lockedBy 数组中,然后再回调调用者 lockAcquired 函数,并将打包传入的 data 以及调用者对应的 id(此时 lockedBy 数组的长度)传入:
接下来在 donateRouter 合约的 lockAcquired 函数中,首先校验调用者是否为 PoolManager 合约,然后解码 data 参数,再调用 PoolManager 合约的 donate 函数:
PoolManager 合约的 donate 函数由 onlyByLocker 以及 noDelegateCall 修饰,不允许通过 delegatecall 调用以及只能在 lock 的状态调用。 donate 函数中调 pool.donate 修改池的状态,并返回一个 delta ,然后调用内部函数 _accountPoolBalanceDelta 去修改对应的 lockState 中 currency 的值:
而在 _accountPoolBalanceDelta 函数中则是调用 _accountDelta 去修改对应的 currency 的 delta 值:
在 _accountDelta 中获取到当前的 lockState 数据,然后根据传入的 delta 计算当前的净余额是否为 0,为 0 则将 lockState 中 nonzeroDeltaCount 减一,而如果原来的净余额为 0 则将 nonzeroDeltaCount 加一,最后修改 lockState 中 currencyDelta 对应的 currency 对应的值。
执行完 PoolManager 合约中 donate 函数的逻辑后执行流回到 lockAcquired 函数中,并执行接下来的转账、调用 PoolManager 的 settle 函数等操作:
先判断代币的净余额是为大于 0 ,大于 0 则判断是否为原生的 ETH,是的话则直接将 ETH 传入并调用 PoolManager 合约的 settle 函数;如果是 ERC20 代币的话则先调用 transferFrom 函数将代币转入 PoolManager 合约,再调用 settle 函数。
settle 函数中先通过 reservesOf 获取内部记录的代币余额,然后在通过 currency.balanceOfSelf() 获取真正的代币余额,相减得到用户转移过来的代币的数量,然后再通过 _accountDelta 修改对应的净余额,这里传入的值做了取负处理,因此数额正确的话,净余额会被修改为 0:
最后 lockAcquired 函数结束后,执行流回到 PoolManager 合约的 lock 函数中,获取对应的 LockState 数据,然后校验是否所有的净余额都为 0,即 PoolManager 合约和调用者两不相欠,最后 pop 出 lockedBy 数组的最后一位 :
整个流程到这里就结束了,核心的地方即是各个操作对 delta 即净余额的修改以及最后的校验,同时 PoolManager 合约的中大部分函数都是这样的调用流程,如 modifyPosition 、swap、mint 等,都被 onlyByLocker 所修饰:
在 Uniswap V2 、V3 中,并不支持使用原生的 ETH 作为代币建立流动性池,而是将 ETH 包装成 WETH 来使用,而在 Uniswap V4 中由于 Singleton 和 Flash accounting 的设计,其支持使用原生的 ETH 建立流动性池。这样做更加节省 Gas,原生 ETH 转账需要的 Gas 仅为 ERC20 代币转账的一半,同时使用时也不需要将 ETH 转换为 WETH。
在 Uniswap V4 的 v4-core-main/contract/libraries/CurrencyLibrary.sol 文件中,定义当 代币的地址为 0 地址则是原生的 ETH,同时提供了 transfer 方法,当代币是 ETH 时使用 call 进行发送:
Uniswap Labs 推出的 Uniswap V4 版本草案发布了新多的创新及优化,如 Hook 的引入,Singleton 模式设计以及 Flash accounting 的设计等等,相信未来会有越来越多的有趣的实验在 V4 上发生,通过 Hook 开发者会开发出创新的代码来扩充流动性池的功能,而用户也能通过 Singleton 的设计使得自己创建流动性池的费用大大降低,同时 Flash accounting 以及 NATIVE ETH 的支持也会使得用户的交易费用变的更低,更加方便。
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。