本系列文章对Uniswap V4进行深入分析,涵盖了其代码结构、架构及其新功能,从Flash Accounting到Hook的应用等,阐述了Uniswap V4的创新点与潜在的挑战,探讨其对DeFi生态的影响。
将持续发布关于Uniswap V4的系列文章。本系列从全面的代码分析开始,逐步深入探讨V4的前景和局限性等方面。文章开始前,笔者声明与Uniswap没有任何关联。_
Uniswap V4系列
1. Uniswap V4 深度剖析 | 1—通过代码了解 V4
2. Uniswap V4 深度剖析 | 2—Uniswap V4的现状与前景
2017年推出的Uniswap,从V1到目前的V3大约经过了6年时间不断发展其协议。其演变过程可总结如下。
最初出现的Uniswap V1是一个非常简单的AMM(Automated Market Maker),仅支持ETH和代币之间的交易,导致了在进行A代币与B代币交易时必须经过ETH的效率低下。
Uniswap V2创建了可以进行代币间交易的池,并通过将ETH包装为ERC20代币WETH来构建池,从而解决了V1的问题。然而,所有价格区间的流动性被均匀分配,因此与池规模相比,交换过程中使用的流动性非常少,这就导致了低效流动性(Lazy Liquidity)的问题。
Uniswap V3使用集中流动性,使得流动性提供者可以决定各自流动性的适用范围,从而在当前价格附近汇聚流动性,提高了资本效率。
(集中流动性在Uniswap V3中的应用 | 来源: Uniswap V3白皮书)
为了实现这一点,V3引入了以“Tick”为中心的复杂机制。这虽然提升了资本效率,但也导致了手续费的增加。
随后在今年6月,Uniswap公布了V4。究竟Uniswap V4解决了哪些问题?Uniswap V4带来的效用或副作用是什么?未来需要解决的问题有哪些?
这篇文章旨在基于公开的代码对这些疑问进行详细分析,尽可能深入和技术性地进行探讨。文章分为两篇,第一篇结合公开的代码分析Uniswap V4的功能,第二篇会讨论围绕Uniswap V4的争议、前景等内容。如果你对Solidity不太熟悉,请跳转到下个链接阅读第二篇。
在进入V4机制分析之前,先了解一下V4中使用的术语概念,以便理解。
Tick是表示Uniswap V3集中流动性中价格的单位。在Uniswap V3中,价格以如下方式表示。
这里的1.0001表示金融中的基点(Basis point),为设计每次移动一个Tick时价格变动1个基点。由于Solidity无法表示小数,因此Uniswap V3使用Q64.96数据类型,在前64位存_integer_部分,后96位存_小数_部分。有关Tick运算的详细内容,请参考Uniswap V3书籍。
(Tick位图索引 | 来源: Uniswap V3书籍)
Tick不仅表示价格,还成为计算手续费和流动性计算的基准点。在Uniswap V3中,Tick 跟踪外部计算的手续费和该Tick内部的流动性,这一结构在V4中仍然延续。
Singleton是一种代码设计模式,表示某个类只有一个实例存在。原本在Uniswap V3中使用工厂模式(Factory pattern),想要创建配对就通过Factory合约部署新的池合约。然而,这样会导致巨额的gas消耗以部署池和进行交换路由。
如今Uniswap V4中的代码将所有池包含在PoolManager
合约中。这样的Singleton架构可以在池的部署和交换路由工作中节省大量的gas费用。
Hook是可以添加到池中的一种插件。Hook通常指的是包含特定事件发生时执行的逻辑的函数。在Uniswap中,事件触发Hook将被分为池部署、流动性提供与回收、交换三类,并据此定义了8种Hook。
通过这使得流动性池不仅仅提供交换功能,还可以进行限价单、MEV收益共享、LP收益最大化等多种功能,从而基于V4构建一种生态系统。
Flash Accounting是通过EIP-1153以廉价、安全的方式执行池相关操作。这一EIP-1153将应用于下一次以太坊硬分叉Cancun-Deneb(DenCun)更新。Transient storage(临时存储)是一种提议,使得存储以与现有存储相同的方式运作,但在交易结束时所有存储的数据将被重置。每个交易结束时数据被删除,因此该存储将最终在不增加以太坊客户端存储的情况下,仅消耗计算资源,相比普通存储的opcode最大节省20倍的gas(100 GAS)。通过这样,Uniswap V4可以在交易中便捷且安全地进行计算和确认工作。
此前每次池操作都需要在池间交换代币,而V4则基于singleton架构和flash accounting,仅调整内部余额,并在最后一次性发送。这种方式大幅降低了gas费用,同时也使得路由或原子交换等操作变得更加简单。
回调函数是从外部接受并执行部分逻辑的函数,主要用于为了代码的可变性和抽象化。
Uniswap中池内的交换函数可以被外部的各种合约调用。代表性的如Uniswap V3流动性头寸自动调整的Arrakis Finance、Gamma、Bunni等DeFi协议。在这里,Uniswap实现了回调函数,允许各个协议自行实现包含自身逻辑的回调合约,从而提高合约的可用性。也就是说,任何人都可以使用Uniswap的核心逻辑,从而提高模块化程度。
Uniswap V4中存在许多数据结构,但本文将挑选出三种必须了解的struct进行分析。
该struct用于在PoolManager合约中识别每个池。其包含池内有哪个代币,交换手续费是多少,Tick间隔是多少,以及使用的Hook是什么。通过哈希化PoolKey struct可决定池的ID,用于区分每个池。
需要注意的是,池识别符中包含了Hook的接口(IHooks
)。也就是说Uniswap V4的每个池只能有一个Hook。同时,构成完全一致但拥有不同Hook的多个池也是可以共存的。例如,具有链上预言机Hook的USDC-ETH池和具有限价单Hook的USDC-ETH池可以共存。
此外,与之前Uniswap V3池子手续费的0.05%、0.3%、1%限制不同,Uniswap V4的手续费没有限制。由此,构成相同但手续费不同的池子可以存在无数。
该struct表示池的状态。与V3相比,实现了信息的Oracle相关趋势与防止重入攻击的unlocked
标志已消失,并包含了有关手续费的信息。该struct会在池部署时(initialize
)初始化。手续费相关变量表示分母的值(例如,如果protocolSwapFee
=20,则协议交换手续费定为1/20=5%)。
需要注意的有两个点。首先,V3中没有的protocolWithdrawFee
在这里新出现。Uniswap DAO在去年12月和今年5月进行了两次关于协议手续费的投票,由于部分投票者对法律问题的担忧,这两次投票均遭到拒绝。随着V4中新出现的protocolWithdrawFee
,该讨论是否会持续进行尚需观察。
第二个是Hook同样在交换或流动性撤回时所收取的手续费功能。此点将在第二部分中详细探讨。
LockState表示用户在池中欠多少(Owed)的金额结构。在这里“欠”意味着与V4核心机制flash accounting的相关性。
Uniswap V4在swap
、modifyPosition
等函数中未直接执行代币传输,而是仅更新状态变量返回结果‘仅计算’。所有代币的传输都在lockAcquired
回调函数内按照固定的逻辑处理。
因此,执行swap、modifyPosition等函数后,就会出现“需向池支付”或“需向池索回”的金额。并存储在LockState内的currencyDelta中,后续通过**take**
和**settle**
函数进行结算。
这里的nonzeroDeltaCount表示尚未结算代币的种类总数,currencyDelta则表示尚未结算的各代币的剩余数量。
(V4 PoolManager的概述)
PoolManager.sol的目的可以用一句话总结为“确保操作结束后,池与用户之间互不欠代币”。为此,PoolManager合约将操作过程区分为1)计算 2)债务清算两个部分。因此,每个方法的目标也如以下所示划分。
initialize
、swap
、modifyPosition
、donate
settle
、take
、lock
包含计算逻辑的方法
这些方法通过调用Pool库进行大多数操作。这是为了将核心计算逻辑封闭在单一实例中。库类似于合约,但只部署一次特定地址内,通过DELEGATECALL
持续重用。Uniswap V4在计算过程中包含复杂的逻辑,因此将其放入单独的Pool库中,以提高代码可读性和逻辑一致性。
initialize
function initialize(PoolKey memory key, uint160 sqrtPriceX96) external override returns (int24 tick) {
...
PoolId id = key.toId();
(uint8 protocolSwapFee, uint8 protocolWithdrawFee) = _fetchProtocolFees(key);
(uint8 hookSwapFee, uint8 hookWithdrawFee) = _fetchHookFees(key);
tick = pools[id].initialize(sqrtPriceX96, protocolSwapFee, hookSwapFee, protocolWithdrawFee, hookWithdrawFee);
...
}
这是在部署池时调用的方法,它接收之前解释过的PoolKey struct作为输入值,再将池的State提取并初始化池。池的初始化是指对应输入价格值的Tick的初始化。这个操作由Pool库内的initialize
函数完成。
function initialize(
State storage self,
uint160 sqrtPriceX96,
uint8 protocolSwapFee,
uint8 hookSwapFee,
uint8 protocolWithdrawFee,
uint8 hookWithdrawFee
) internal returns (int24 tick) {
if (self.slot0.sqrtPriceX96 != 0) revert PoolAlreadyInitialized();
tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96);
self.slot0 = Slot0({
sqrtPriceX96: sqrtPriceX96,
tick: tick,
protocolSwapFee: protocolSwapFee,
hookSwapFee: hookSwapFee,
protocolWithdrawFee: protocolWithdrawFee,
hookWithdrawFee: hookWithdrawFee
});
}
在上述代码中,基于部署时决定的价格和手续费信息填入Slot0信息,完成对池的初始化。与V3中需重新部署池合约不同,V4中池的部署逻辑大幅简化。
swap
通过调用Pool库的swap函数执行核心逻辑的函数。函数通过调用Pool库内的swap
函数进行计算。
function swap(PoolKey memory key, IPoolManager.SwapParams memory params)
external
override
noDelegateCall
onlyByLocker
returns (BalanceDelta delta)
{
...
Pool.SwapState memory state;
PoolId id = key.toId();
(delta, feeForProtocol, feeForHook, state) = pools[id].swap(
Pool.SwapParams({
fee: totalSwapFee,
tickSpacing: key.tickSpacing,
zeroForOne: params.zeroForOne,
amountSpecified: params.amountSpecified,
sqrtPriceLimitX96: params.sqrtPriceLimitX96
})
);
_accountPoolBalanceDelta(key, delta);
// 手续费在输入货币上
...
}
与V3类似,Pool库的swap
函数在交换完成前会持续执行计算(while循环)。交换完成的条件有两个:
V4的变化在于交换结束后会返回delta
值。delta
表示在执行交换、流动性提供等操作时,池内余额的变化。其构成如下。
function toBalanceDelta(int128 _amount0, int128 _amount1) pure returns (BalanceDelta balanceDelta) {
/// @solidity memory-safe-assembly
assembly {
balanceDelta :=
or(shl(128, _amount0), and(0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff, _amount1))
}
}
这一值根据下图,将需调整的amount0
与amount1
填入int256数据类型中。
通过此值,再调用_accountPoolBalanceDelta
。该函数用于记录成对的两种代币的各自delta
值(需要返还给 池和需要从池索取的代币数量)。
/// @dev 将余额变化汇总到货币映射至余额变化
function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta) internal {
_accountDelta(key.currency0, delta.amount0());
_accountDelta(key.currency1, delta.amount1());
}
function _accountDelta(Currency currency, int128 delta) internal {
if (delta == 0) return;
LockState storage lockState = lockStates[lockedBy.length - 1];
int256 current = lockState.currencyDelta[currency];
int256 next = current + delta;
unchecked {
if (next == 0) {
lockState.nonzeroDeltaCount--;
} else if (current == 0) {
lockState.nonzeroDeltaCount++;
}
}
lockState.currencyDelta[currency] = next;
}
在_accountDelta
函数中,根据输入的delta
值更新lockState struct。交换后delta
非0,因此在这里nonzeroDeltaCount
会加1并且currencyDelta
会增加delta
值。
记录下来的nonzeroDeltaCount
和currencyDelta
值将在后续的settle
和take
函数中进行结算。
modifyPosition
执行流动性提供、修改、回收等核心逻辑的函数。与swap
函数相似,它调用Pool库的modifyPosition
函数以返回delta
值并反映在池信息中。
Pool库内modifyPosition
函数由初始化指定的Tick、更新手续费信息及返回池的delta
值这三部分组成。
用户希望提供或回收流动性的范围后,激活相应的Tick(flipTick
)。
提取当前LP头寸内的数据累计手续费信息。在进行流动性回收或范围调整等操作时提取所需的手续费信息。
delta
值之后计算池的delta
值并加至返回值result。
返回的delta
值同样通过swap
函数中存入的_amountPoolBalanceDelta
函数记录在lockState struct中,之后使用settle
和take
函数完成结算。
donate
此函数是V4新增的功能,用于向池中捐赠代币以激励LP。swap
、modifyPosition
与之类似,均以获取delta
值为重点,并在Pool库内执行如下核心逻辑。
function donate(State storage state, uint256 amount0, uint256 amount1) internal returns (BalanceDelta delta) {
if (state.liquidity == 0) revert NoLiquidityToReceiveFees();
delta = toBalanceDelta(amount0.toInt128(), amount1.toInt128());
unchecked {
if (amount0 > 0) {
state.feeGrowthGlobal0X128 += FullMath.mulDiv(amount0, FixedPoint128.Q128, state.liquidity);
}
if (amount1 > 0) {
state.feeGrowthGlobal1X128 += FullMath.mulDiv(amount1, FixedPoint128.Q128, state.liquidity);
}
}
}
捐赠者送出的金额将被加入到累计池手续费中。这可以用作提供给LP以支持TWAMM的必要流动性,或创建新的手续费体系等用途。
swap
、modifyPosition
、donate
函数均未执行真正的代币传输,而只是计算因操作需要变更的代币数量delta
。接下来了解计算的delta
值是如何通过过程进行清算的。
清算计算结果及进行代币交互的方法
settle
function settle(Currency currency) external payable override noDelegateCall onlyByLocker returns (uint256 paid) {
uint256 reservesBefore = reservesOf[currency];
reservesOf[currency] = currency.balanceOfSelf();
paid = reservesOf[currency] - reservesBefore;
// 计算前需确保安全
_accountDelta(currency, -(paid.toInt128()));
}
settle
是用户清偿债务的函数。settle
函数在用户向PoolManager合约发送代币之后调用。可以以如下示例表达settle
函数的计算过程。
假设用户处于欠1 ETH的状态,当前池的ETH仓位(reserveOf[currency]
)为5 ETH。那么,当用户发送1 ETH至PoolManager并调用settle
函数时,会进行以下的计算工作。
第一行:在reserveOf[currency]
尚未更新前,初值为5 ETH,所以reserveBefore = 5
。
第二行:通过调用更新PoolManager的余额值的balanceOfSelf()
函数时,reserveOf[currency]
更新为6 ETH。
第三行:paid = 6 - 5 = 1
。
第四行:输入货币为ETH,paid = 1
传递给_accountDelta
进行下一步清算。
此时被调用的_accountDelta
是将swap
或modifyPosition
中所记录的delta
值进行清算。
function _accountDelta(Currency currency, int128 delta) internal {
if (delta == 0) return;
LockState storage lockState = lockStates[lockedBy.length - 1];
int256 current = lockState.currencyDelta[currency];
int256 next = current + delta;
unchecked {
if (next == 0) {
lockState.nonzeroDeltaCount--;
} else if (current == 0) {
lockState.nonzeroDeltaCount++;
}
}
lockState.currencyDelta[currency] = next;
}
调用此函数后,池用户间互不欠代币(delta = 0
),满足池操作正常完成的条件。
take
function take(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {
_accountDelta(currency, amount.toInt128());
reservesOf[currency] -= amount;
currency.transfer(to, amount);
}
该函数用于清算并转移用户应得的代币。与settle
相似,通过_accountDelta
函数进行代币清算,接着执行从池到用户的代币转账操作。
lock
& lockAcquired
/// @notice 所有操作通过该函数进行
/// @param data 任何数据通过 `ILockCallback(msg.sender).lockCallback(data)` 传递给回调函数
/// @return 调用 `ILockCallback(msg.sender).lockCallback(data` 的返回数据
function lock(bytes calldata data) external override returns (bytes memory result) {
uint256 id = lockedBy.length;
lockedBy.push(msg.sender);
// 调用者在此回调中完成所有操作,包括通过调用settle清偿欠款
result = ILockCallback(msg.sender).lockAcquired(id, data);
...
}
lock
函数是Uniswap V4所有操作的起点。该函数将调用者记录到lockedBy
数组中。lockedBy
是表示与池之间的“债务”关系用户的列表,交易正常结束后调用者将从该数组中删除。
随后调用ILockCallback
中的lockAcquired
函数。
在lockAcquired
函数中执行与用户交互的核心逻辑,并且此函数由回调合约实施。由于目前还没有标准化的回调合约,我们来看一下基于Uniswap V4 GitHub的测试代码的简单实现。
function lockAcquired(uint256, bytes calldata rawData) external returns (bytes memory) {
require(msg.sender == address(manager));
CallbackData memory data = abi.decode(rawData, (CallbackData));
BalanceDelta delta = manager.swap(data.key, data.params);
if (data.params.zeroForOne) {
if (delta.amount0() > 0) {
if (data.testSettings.settleUsingTransfer) {
if (data.key.currency0.isNative()) {
manager.settle{value: uint128(delta.amount0())}(data.key.currency0);
} else {
IERC20Minimal(Currency.unwrap(data.key.currency0)).transferFrom(
data.sender, address(manager), uint128(delta.amount0())
);
manager.settle(data.key.currency0);
}
} else {
// 该传输时接收的Hook将销毁代币
manager.safeTransferFrom(
data.sender,
address(manager),
uint256(uint160(Currency.unwrap(data.key.currency0))),
uint128(delta.amount0()),
""
);
}
}
if (delta.amount1() < 0) {
if (data.testSettings.withdrawTokens) {
manager.take(data.key.currency1, data.sender, uint128(-delta.amount1()));
} else {
manager.mint(data.key.currency1, data.sender, uint128(-delta.amount1()));
}
}
...
以上代码是回调合约PoolSwapTest合约中lockAcquired
函数。在这里,首先调用PoolManager合约内的swap
函数。正如前文所述,swap
函数将在此计算返回的余额变化(delta)值。
例如,假设ETH-USDC池中,现在进行1个ETH交换2000个USDC的操作。如果没有触发设定的滑点限制,通过swap
函数计算出的delta
值会如下显示(符号表示需向池支付为+,需从池收回为-)。
接着按顺序进行以下操作。
IERC20.transferFrom
函数传送至PoolManager。settle
函数进行1 ETH的清算。take
函数将2000 USDC发送给用户,并清算掉delta.amount1
。清算后的delta
值为0,而lockAcquired
函数将该值返回至lock
函数内。在lockAcquired
回调结束后,最终执行下面的逻辑。
// function lock()
unchecked {
LockState storage lockState = lockStates[id];
if (lockState.nonzeroDeltaCount != 0) revert CurrencyNotSettled();
}
lockedBy.pop();
}
在此确保lockState内nonzeroDeltaCount为0时,才继续进行交易的结果,这作为一种安全保障机制。这意味着仍有未结算的代币存在,且在lockAcquired
执行过程中出现错误或恶意攻击。我们的示例中,由于成功执行了交换,因此lockState内的nonzeroDeltaCount
和currencyDelta
均为0。
最后从lockedBy
数组中将调用者删除,交易顺利结束。
假设回调合约为Uniswap V4 GitHub中的测试代码PoolSwapTest.sol,用户向池发起交换请求的全过程如下整理:
(Uniswap V4的交换调用流程)
swap
函数。lock
函数。lock
函数继而调用回调合约内的lockAcquired
函数。lockAcquired
中调用PoolManager合约内的三个函数。首先,调用swap
函数。该函数将保存由于交换引起的delta
值(+1 ETH, -2000 USDC)。settle
函数。该函数将ETH的delta
值归零。take
函数。此函数首先将USDC的delta
值清算为0,然后发送2000 USDC给用户。lock
函数完成lockState内变量所有值检查都为0时,交换交易完成。从上述调用流程可见,指令执行的发起地点是lockAcquired
函数。在Uniswap V4中,任何人都可编写此函数并以回调合约的形式发布和自定义,这意味着基于Uniswap V4会推出各种新项目。
initialize
、swap
、modifyPosition
函数中执行。每个函数执行完成后的代码中都可以看到调用 Hook 的部分,如下所示。// 在 PoolManager.sol 的 swap() 方法中
if (key.hooks.shouldCallBeforeSwap()) {
if (key.hooks.beforeSwap(msg.sender, key, params) != IHooks.beforeSwap.selector) {
revert Hooks.InvalidHookResponse();
}
}
// 交换逻辑
if (key.hooks.shouldCallAfterSwap()) {
if (key.hooks.afterSwap(msg.sender, key, params, delta) != IHooks.afterSwap.selector) {
revert Hooks.InvalidHookResponse();
}
}
如上所述,PoolKey 结构体中包含 Hook 的接口。在该接口中查找并执行 beforeSwap
、afterSwap
等 Hook 所具有的逻辑。
为了更方便地区分和查找 Uniswap V4 中每个 Hook 的功能,使用了一种巧妙的方式,通过 Hook 合约的地址前两位来确定功能。如下所示,首先为不同功能指定标志,然后将标志信息存储在 Hook 地址的前两位。
(Uniswap Hook 地址识别 | 来源: Uniswap Github)
如果 Hook 合约的地址是 0x9000000000000000000000000000000000000000
,那么前两位是 0x90
,转换为二进制为 1001000
,所以该 Hook 具有 beforeInitialize
和 afterModifyPosition
两个功能。这使得 Uniswap V4 能够提高 Hook 的搜索和交换路由的效率。
为了加深对 Hook 的理解,我们来分析一下 Uniswap V4 Github 中的 LimitOrder(限价订单)Hook。在 V4 中,限价订单通过在极小的范围(Tick 单位)内提供流动性来运作。限价订单提交通过 place
方法进行。
function place(PoolKey calldata key, int24 tickLower, bool zeroForOne, uint128 liquidity)
external
onlyValidPools(key.hooks)
{
if (liquidity == 0) revert ZeroLiquidity();
poolManager.lock(
abi.encodeCall(this.lockAcquiredPlace, (key, tickLower, zeroForOne, int256(uint256(liquidity)), msg.sender))
);
...
所有提交限价订单的地址都拥有各自的包含订单信息的 EpochInfo 结构体。根据输入的参数,保存各自的 EpochInfo 结构体,并在 place
函数结束时更新。
EpochInfo storage epochInfo;
Epoch epoch = getEpoch(key, tickLower, zeroForOne);
epochInfo = epochInfos[epoch];
unchecked {
epochInfo.liquidityTotal += liquidity;
epochInfo.liquidity[msg.sender] += liquidity;
}
emit Place(msg.sender, epoch, key, tickLower, zeroForOne, liquidity);
假设在如下的 ETH-USDC 池中,目前 ETH 的价格在索引 0,而我的限价订单分别存在于索引 2 和 4。
然后,假设发生了大规模的交换,ETH 的价格上涨到了索引 9。那么,我的两个订单将全部执行,提供的 ETH 都将转换为 USDC,流动性也需全部撤回以完成订单。因此,在交换操作结束后,LimitOrder 合约的 afterSwap
函数将被调用。
function afterSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata params, BalanceDelta)
external
override
poolManagerOnly
returns (bytes4)
{
(int24 tickLower, int24 lower, int24 upper) = _getCrossedTicks(key.toId(), key.tickSpacing);
if (lower > upper) return LimitOrder.afterSwap.selector;
bool zeroForOne = !params.zeroForOne;
for (; lower <= upper; lower += key.tickSpacing) {
Epoch epoch = getEpoch(key, lower, zeroForOne);
if (!epoch.equals(EPOCH_DEFAULT)) {
EpochInfo storage epochInfo = epochInfos[epoch];
epochInfo.filled = true;
(uint256 amount0, uint256 amount1) = abi.decode(
poolManager.lock(
abi.encodeCall(this.lockAcquiredFill, (key, lower, -int256(uint256(epochInfo.liquidityTotal))))
),
(uint256, uint256)
);
unchecked {
epochInfo.token0Total += amount0;
epochInfo.token1Total += amount1;
}
setEpoch(key, lower, zeroForOne, EPOCH_DEFAULT);
emit Fill(epoch, key, lower, zeroForOne);
}
}
该函数的作用是提示所有由于价格变化而在当前价格与先前价格之间的限价订单已经被处理,并回收流动性。回收将通过调用 PoolManager 的 lock
函数来执行。
尽管在 V3 中也可以进行这种形式的限价订单,但用户需要手动执行流动性提供和撤回的操作,这存在不便之处。通过 LimitOrder Hook,V4 实现了这一过程的自动化,从而改善了用户体验,提供了限价订单的基础设施。
不仅如此,Hook 可以包含非常多样化的功能。除了 LimitOrder 之外,Uniswap V4 Github 中还提供了官方示例的 Hook,如下:
此外,还有能够提供 MEV 收益的返还功能,或将超出范围的流动性存入借贷协议以最大化流动性提供者收益的 Hook等多种形式的 Hook。
基于以上代码分析,可以将 Uniswap V4 的功能总共归纳为四个方面。
Uniswap V4 的核心功能之一是 Flash Accounting。对池进行特定操作时,池内资产的余额变化会存储在 Lockstate 结构体内的 nonzeroDeltaCount
和 currencyDelta
中。这些数据在操作执行之前和之后都必须计算为 0。也就是说,Lockstate 与操作路径无关(path-independent),在操作前后都具有相同值的结构体。
引入这种数据类型的原因是为了基于 EIP-1153 应用闪电会计。目前尚未实现 EIP-1153,因此 Lockstate 被存储在存储中,但 Uniswap 计划在未来 Dencun 更新后将 Lockstate 结构体和 lockedBy 数组声明为临时存储变量。这样就能在计算过程中节省 gas 费用,同时确保在交易前后该变量的值保持为 0,从而保障交换和流动性供给过程的完整性。
与 V3 相比,这在安全费用上有更低廉的意义。
在 V3 中,采用如下的重入保护机制以防止针对交换、流动性撤回的重入攻击。
modifier lock() {
require(slot0.unlocked, 'LOK');
slot0.unlocked = false;
_;
slot0.unlocked = true;
}
这里的 slot0.unlocked
是存储中的变量,由于每次更新都要修改存储,因此最多会消耗 22,100 GAS。而修改临时存储的费用为 100 GAS,成本极低。即,声名 LockState 和 lockedBy 到临时存储后,安全费用可以节约 95% 以上。
关于临时存储和 EIP-1153 的更多信息可以参考 这篇文章。
与之前的 Uniswap 代码将其分为 Factory / Pool 两个部分并为每个池创建新合约不同,V4 使用 PoolManager 合约来管理所有池。因此,池之间的路由费用更便宜。
例如,考虑将 ETH 兑换成 USDC,然后将 USDC 再兑换成 STG Token的路由操作。在 V4 之前,过程是将 ETH 放入 ETH-USDC 池以获得 USDC,然后将其传输到 USDC-STG 池以获得 STG。然而在 V4 中,只需更新每个池的内部余额变化信息 delta
,就能完成转换。中间完全不需要进行代币传输,仅在开始和结束时修改存储,从而简化并便宜了路由过程。
此外,池的分配费用也变得非常低廉。之前分配池需要单独部署一个池合约,但在 V4 中,调用 PoolManager 合约中的 initialize
函数将会部署池,从而节省约 99% 以上的部署费用。
V4 的每个池通过 Hook 为用户提供多种功能。正如上述的限价订单示例所示,Hook 可以增强用户的体验,但是也可以承担以下功能:
基于这些 Hook,Uniswap V4 池能够为交易者和流动性提供者提供额外收益和功能。
与此同时,根据所选择的 Hook,池的类别被区分,因此可能会加剧流动性的碎片化。对此将在第 2 部分详细讨论。
(Uniswap V3 和 V4 的池操作所需 Gas 在对比)
基于 Uniswap V3 和 V4 的代码进行的 Gas 测试结果如下。然而与以上所述内容相反,排除池的分配费用,在交换和流动性供应方面 V4 所需的 Gas 费用高于 V3 或者相似水平。
这是因为 EIP-1153 尚未实施。在 lock
函数中,计算 lockedBy 数组和 Lockstate 的过程中,每次执行都将包含存储中的 SSTORE
操作码,这个操作的成本目前非常高。SSTORE
根据尝试存储的值是否为 0、首次访问否等区别其成本,如下所示。
目前 V4 中,Lockstate 结构体和 lockedBy 数组被命名为存储。在交换过程中,lock
和 lockAcquired
函数中关于的 SSTORE
操作总共发生了 12 次,消耗大约 138,500 GAS。
不过,如果该变量使用映射到临时存储的 TSTORE
操作码,则所需的 gas 费用将如何变化呢?TSTORE
每次操作所需 gas 固定为 100 GAS,因此,消耗的 gas 将从 138,500 GAS 降低到 1,200 GAS。这意味着变量存储的成本减少了 99% 以上,整个交换成本将比 V3 低最大 52%。也就是说,如果 EIP-1153 实现在以太坊客户端,则预计 V4 的交换和流动性提供/撤回的 Gas 流量将比 V3 低得多。
可以将 Uniswap V4 简要总结为三点:
lock
、lockAcquired
)。然而,也有批评的声音认为这些功能在其他协议中已经存在,并且流动性碎片化将进一步加剧。在下一篇文章中,我们将讨论围绕 V4 的各种争议,并探讨未来 V4 生态系统将如何发展。
参考文献
- 原文链接: medium.com/decipher-medi...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!