概述
在上一篇博客内,我们详细介绍了 Uniswap V3 的基础原理。在本篇博客内,我们将继续介绍与 Uniswap V3 差别不大的 Uniswap v4 的原理及代码。需要注意的,Uniswap v4 的 AMM 曲线部分与 Uniswap v3 是一致的,但增加了一些优化的业务逻辑。本文将跳过所有与 Uniswap v3 类似的部分,只介绍 Uniswap v4 的特性。
本文将主要介绍 Uniswap V4 的以下特性:
- Singleton Pool。所有的代币的交换池都位于单一合约内部,降低了在链式兑换时的 gas 消耗
- Flash Accounting。一个用于跟踪交易过程中净流入和净流出代币的系统,只需要用户保证一笔交易中最后的代币平衡即可,并不需要向 Uniswap v3 一样保持交易中每一次 swap 调用的平衡
- Hooks。最为知名的特性,为 Uniswap v4 增加了巨大的可拓展性
- ERC 6909。一个类似 ERC1155 的协议,使用该 ERC 标准实现了用户可以将部分资产存入 Uniswap V4 内部已实现更加高效的兑换
ERC 6909
与其他文章不同,本文首先介绍 Uniswap V4 目前最不为大众所熟知的 ERC6909 协议。ERC 6909 也被 Uniswap V4 的核心合约 PoolManager
所继承。
ERC6909 在接口上与 ERC1155 是类似的。该合约可以实现多种 ERC20 代币存放在同一个合约内,并在同一个合约内管理。比如用户可以使用 function balanceOf(address owner, uint256 id) external view returns (uint256 amount);
函数查询某一个地址名下的某一个 ERC20 代币在 ERC6909 内的持有数量。而 function allowance(address owner, address spender, uint256 id) external view returns (uint256 amount);
则可以查询地址之间的对某一个资产的授权数量。
ERC6909 类似包装代币,一种最简单且精准的类比是认为 ERC6909 是一种将 ERC20 代币包装为 ERC1155 代币的方案。当然,我们也可以看到 ERC6909 依靠单个合约管理多种资产的思想也类似 Uniswap V4 的 Singleton Pool 的设计思路。
Uniswap V4 在标准的 ERC6909 接口外,还提供了 mint
函数:
function mint(address to, uint256 id, uint256 amount) external onlyWhenUnlocked {
unchecked {
Currency currency = CurrencyLibrary.fromId(id);
// negation must be safe as amount is not negative
_accountDelta(currency, -(amount.toInt128()), msg.sender);
_mint(to, currency.toId(), amount);
}
}
我们先不关心函数内的其他部分,可以看到该函数实现了 _mint
方法,可以为用户铸造某种代币的 ERC6909 的包装版本。
上述函数并没有具体的代币转入流程,这是因为 Uniswap V4 引入了 Flash Accounting 机制,我们会在合约的闪存(
transient storage
) 内存入代币的盈亏数值,并在交易完成时做最终结算。我们可以看到此处使用_accountDelta(currency, -(amount.toInt128()), msg.sender);
语句在为用户减少了一部分资产。
当然,Uniswap V4 也提供了解除包装的函数,该函数被称为 burn
函数:
function burn(address from, uint256 id, uint256 amount) external onlyWhenUnlocked {
Currency currency = CurrencyLibrary.fromId(id);
_accountDelta(currency, amount.toInt128(), msg.sender);
_burnFrom(from, currency.toId(), amount);
}
与上述 mint
函数相反,此处使用 _accountDelta(currency, amount.toInt128(), msg.sender);
增加了用户的可用资产。
此处我们可以讨论一下上述函数中的 CurrencyLibrary
中的 toId
和 fromId
函数,前者用于代币地址向 ERC6909 的 ID 转化,而后者则用于 ID 向地址的转换。上述代码都定义在 src/types/Currency.sol
内部,这部分代码是相当有意思的,因为 Uniswap 使用了大量的 solidity 新语法。
在 Currency.sol
的开始部分,我们就可以看到如下代码:
type Currency is address;
using {greaterThan as >, lessThan as <, greaterThanOrEqualTo as >=, equals as ==} for Currency global;
using CurrencyLibrary for Currency global;
function equals(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) == Currency.unwrap(other);
}
function greaterThan(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) > Currency.unwrap(other);
}
function lessThan(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) < Currency.unwrap(other);
}
function greaterThanOrEqualTo(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) >= Currency.unwrap(other);
}
此处我们首先使用了 type Currency is address;
为 address
类型创建别名。然后使用 using A for B
的语法为 Currency
类型增加了一些特性。需要注意的,此处使用了操作运算符的定义,所以在最后增加了 global
关键词。
之后的代码中出现了 Currency.unwrap
函数,该函数也是新版本 solidity 为用户自定义类型增加的新特性。在较新版本的 solidity 中,假如用户定义了 type C is V
,其中 C
是用户自定义类型,而 V
是 solidity 提供的原始类型,那么用户可以使用 C.wrap
将 V
类型转化为 C
类型,也可以使用 C.unwrap
将 V
类型转化为 C
类型。使用这种方法的好处是,solidity 会提供默认的类型检查,避免错误的类型转化发生。
Uniswap v4 此处的代码就显示了在新版本 solidity 下,开发者该如何定义自己的类型以及为类型重载运算符。如果读者希望进一步了解相关内容,可以参考 User-defined Value Types 部分的文档。
在 CurrencyLibrary
内部,Uniswap V4 定义了以下函数:
function toId(Currency currency) internal pure returns (uint256) {
return uint160(Currency.unwrap(currency));
}
// If the upper 12 bytes are non-zero, they will be zero-ed out
// Therefore, fromId() and toId() are not inverses of each other
function fromId(uint256 id) internal pure returns (Currency) {
return Currency.wrap(address(uint160(id)));
}
这两段函数较为简单,但注释指出 fromId()
和 toId()
并不是完全互逆的。这是因为假如用户给出了一个特殊的 id
,比如给定 0x1000000000000000000000001f9840a85d5af5bf1d1762f925bdaddc4201f984
的 ID,那么由于 uint160(id)
会截断大于 160 bit 的部分,所以最终返回的结果会是 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984
代币地址。所以存在多个 ID 对应同一种代币的情况。故此,Uniswap v4 注释内指出 fromId
和 toId
并不是完全互逆关系。
ERC 7751 和 Custom Revert
为了优化异常的抛出,Uniswap v4 引入了 ERC 7751 协议,并编写了 CustomRevert
库来处理 Revert。所谓的 ERC 7751 是一种带有上下文的错误抛出方法。一个简单的应用场景是当 Uniswap V4 出现报错后,在之前的情况下,我们不知道是 Uniswap v4 的主合约给出的报错还是因为 ERC20 代币实现给出的报错,我们只能通过 trace 的方式发现调用出错的位置。但 ERC 7751 允许我们给出报错的合约地址以及相关的上下文,这可以使得开发者不在 trace 的情况下就可以快速判断问题。
上述功能在 src/libraries/CustomRevert.sol
内的 bubbleUpAndRevertWith
函数内进行了实现:
/// @notice bubble up the revert message returned by a call and revert with a wrapped ERC-7751 error
/// @dev this method can be vulnerable to revert data bombs
function bubbleUpAndRevertWith(
address revertingContract,
bytes4 revertingFunctionSelector,
bytes4 additionalContext
) internal pure {
bytes4 wrappedErrorSelector = WrappedError.selector;
assembly ("memory-safe") {
// Ensure the size of the revert data is a multiple of 32 bytes
let encodedDataSize := mul(div(add(returndatasize(), 31), 32), 32)
let fmp := mload(0x40)
// Encode wrapped error selector, address, function selector, offset, additional context, size, revert reason
mstore(fmp, wrappedErrorSelector)
mstore(add(fmp, 0x04), and(revertingContract, 0xffffffffffffffffffffffffffffffffffffffff))
mstore(
add(fmp, 0x24),
and(revertingFunctionSelector, 0xffffffff00000000000000000000000000000000000000000000000000000000)
)
// offset revert reason
mstore(add(fmp, 0x44), 0x80)
// offset additional context
mstore(add(fmp, 0x64), add(0xa0, encodedDataSize))
// size revert reason
mstore(add(fmp, 0x84), returndatasize())
// revert reason
returndatacopy(add(fmp, 0xa4), 0, returndatasize())
// size additional context
mstore(add(fmp, add(0xa4, encodedDataSize)), 0x04)
// additional context
mstore(
add(fmp, add(0xc4, encodedDataSize)),
and(additionalContext, 0xffffffff00000000000000000000000000000000000000000000000000000000)
)
revert(fmp, add(0xe4, encodedDataSize))
}
}
上述代码使用了 yul 汇编完成,虽然看上去很复杂,但实际上很简单。上述代码本质上是抛出了以下错误的 ABI 编码版本:
error WrappedError(address target, bytes4 selector, bytes reason, bytes details);
在 bubbleUpAndRevertWith
函数内,details
只是一个 bytes4 additionalContext
类型。上述代码本质上是在完成了 WrappedError
的 ABI 编码工作。我们可以分部分来研究上述代码的功能:
第一部分是用来计算调用合约的返回值占据的字节数:
let encodedDataSize := mul(div(add(returndatasize(), 31), 32), 32)
在 WrappedError
内,我们会给出调用其他合约返回的报错内容,这部分报错内容都存储在 call 之后的返回值内,我们可以通过 returndatasize
获得这部分错误返回值的大小。由于 solidity 内都以 32 bytes 作为基础单位,所以此处我们需要将 returndatasize
计算为 32 的倍数。具体逻辑实现上,我们首先增加将 returndatasize
增加 31
,这是为了处理 returndatasize = 1
这种情况。即使 returndatasize = 1
,使用上述代码仍可以计算出 32
的大小。其他的先除(div
)后乘(mul
) 是一种典型的将每一个数字重整为另一个数字倍数的方法。我们可以在 Uniswap v3 内的 tickSpacingToMaxLiquidityPerTick
函数内看到类似的操作。
第二部分使用 let fmp := mload(0x40)
语句获取当前 solidity 的空闲内存地址的起点。solidity 编译器使用了动态的内存布局方案,每次使用或者释放内存后,都会修改 0x40
内的数据,将其指向当前未使用的空闲内存的起点。在大部分内存安全的代码方案内,我们都会 mload(0x40)
读取空闲内存起点,避免后续占用到已被其他函数使用到的内存造成安全问题。
第三部分用来写入 WrappedError
错误选择器以及报错的合约地址。
mstore(fmp, wrappedErrorSelector)
mstore(add(fmp, 0x04), and(revertingContract, 0xffffffffffffffffffffffffffffffffffffffff))
与 solidity 定义的 log
方法一致,solidity 内抛出错误实际上也会计算错误的选择器,然后将其作为最初的 4 bytes。此处的 wrappedErrorSelector
就首先被写入了内存,然后跳过 wrappedErrorSelector
占据的最初 4 bytes(add(fmp, 0x04)
就是跳过 wrappedErrorSelector
占据的字节),我们接下来需要写入给出报错的地址。根据 solidity 的彬吗规范,给出报错的地址应该占据 256 bit。此处使用 and(revertingContract, 0xffffffffffffffffffffffffffffffffffffffff))
清理了 revertingContract
变量可能存在的高位垃圾,然后将其写入内存。
此时的内存结构如下:
wrappedErrorSelector (4 bytes) + revertingContract(32 bytes)
第四部分写入出现报错的 selector
和错误函数的返回值。比如 Uniswap V4 调用 ERC20 代币的 transfer
失败,那么此处就写入 transfer
的选择器和返回的错误信息:
mstore(
add(fmp, 0x24),
and(revertingFunctionSelector, 0xffffffff00000000000000000000000000000000000000000000000000000000)
)
上述代码完成了 selector
的写入,因为 selector
的类型是 bytes4
,所以此处直接写入内存即可,但注意 bytes4
在 ABI 编码后会转化为 uint256
。我们需要计算写入时需要跳过的内存长度。我们需要跳过 wrappedErrorSelector (4 bytes) + revertingContract(32 bytes)
的内存结构,该结构的长度为 36(0x24
),所以我们此处使用 add(fmp, 0x24)
确定了起始位置,然后写入了 revertingFunctionSelector
,此处也需要注意使用与0xffffffff00000000000000000000000000000000000000000000000000000000
进行 and
操作去掉其他垃圾位。
完成上述操作后,内存结构如下:
wrappedErrorSelector (4 bytes)
+ revertingContract(32 bytes)
+ revertingFunctionSelector(32 bytes)
第五部分内,我们写入动态类型的 offset。因为我们最后写入的 reason
和 detail
都是 bytes
类型,该类型要求写入动态类型的起始位置。在后文内,我们称之为 reason offset
和 detail offset
。代码如下:
// offset revert reason
mstore(add(fmp, 0x44), 0x80)
// offset additional context
mstore(add(fmp, 0x64), add(0xa0, encodedDataSize))
此处,我们首先确定 reason offset
的写入位置 ,我们目前的内存已占用了 4 + 32 + 32 = 68
,所以使用 add(fmp, 0x44)
跳过之前已写入的数据。而关于 bytes
动态类型的起始位置计算则需要排除 wrappedErrorSelector (4 bytes)
,这一点额外重要,即计算动态类型的 offset 不需要包括选择器的占用部分。所以计算获得的 reason offset
应该为 revertingContract(32 bytes) + revertingFunctionSelector(32 bytes) + reason offset(32 bytes) + details offset(32 bytes)
。我们使用 mstore(add(fmp, 0x44), 0x80)
写入 reason offset
。
接下来,我们需要 detail offset
,该变量的写入位置为 wrappedErrorSelector (4 bytes) + revertingContract(32 bytes) + revertingFunctionSelector(32 bytes) + reason offset(32 bytes) = 0x64
,而写入的数值是 add(0xa0, encodedDataSize)
。该数值中的 0xa0
包含以下部分 revertingContract(32 bytes) + revertingFunctionSelector(32 bytes) + reason offset(32 bytes) + reason length(32 bytes) + detail offset(32 bytes) = 160
,除此之外还包含 encodedDataSize
部分。
在具体的编码过程中,我们往往最后确认 offset 的数值,所以动态类型的 offset 并没有非常难以确认
上述代码编写完成后,内存布局如下:
wrappedErrorSelector (4 bytes)
+ revertingContract(32 bytes)
+ revertingFunctionSelector(32 bytes)
+ reason offset(32 bytes)
+ detail offset(32 bytes)
接下来,我们可以写入 reason
的真实内容。此处需要注意写入 bytes 类型的内容,需要在原有内容前增加长度信息。我们先完成 reason
的长度写入然后完成具体的内容写入:
// size revert reason
mstore(add(fmp, 0x84), returndatasize())
// revert reason
returndatacopy(add(fmp, 0xa4), 0, returndatasize())
此处使用 returndatacopy
将失败的调用的返回结果写入内存。读者可以自行阅读 文档 获得 returndatacopy
的参数。
完成上述操作后内存布局为:
wrappedErrorSelector (4 bytes)
+ revertingContract(32 bytes)
+ revertingFunctionSelector(32 bytes)
+ reason offset(32 bytes)
+ detail offset(32 bytes)
+ reason length(32 bytes)
+ return data(encodedDataSize)
最后,我们写入 detail
的内容。Uniswap v4 约定 detail
是一个 bytes4 additionalContext
参数,所以长度确定为 4。相关代码如下:
// size additional context
mstore(add(fmp, add(0xa4, encodedDataSize)), 0x04)
// additional context
mstore(
add(fmp, add(0xc4, encodedDataSize)),
and(additionalContext, 0xffffffff00000000000000000000000000000000000000000000000000000000)
)
此处也使用了 and
去掉垃圾位。完成上述所有步骤后,我们的内存布局如下:
wrappedErrorSelector (4 bytes)
+ revertingContract(32 bytes)
+ revertingFunctionSelector(32 bytes)
+ reason offset(32 bytes)
+ detail offset(32 bytes)
+ reason length(32 bytes)
+ return data(encodedDataSize)
+ detail length(32 bytes)
+ detail data(32 bytes)
完成上述所有工作后,我们直接使用 revert(fmp, add(0xe4, encodedDataSize))
抛出报错。代码内的 0xe4
实际上就是除了 return data
外,其他固定长度部分之和。
由于上述代码最后使用 revert
结束,所以此处我们没有进行内存的清理工作,但其他函数内,开发者如果使用内联汇编操作内存,应当考虑内存的清理工作。
在 CurrencyLibrary
库内,我们使用了上述函数报错 transfer
的错误:
if (!success) {
CustomRevert.bubbleUpAndRevertWith(
Currency.unwrap(currency), IERC20Minimal.transfer.selector, ERC20TransferFailed.selector
);
}
考虑到文章的长度,此处不会详细介绍 CurrencyLibrary
内给出的 transfer
函数的构造方法,读者可以自行阅读 yul 源代码。该函数最后就使用了以下代码清理内存:
// Now clean the memory we used
mstore(fmp, 0) // 4 byte `selector` and 28 bytes of `to` were stored here
mstore(add(fmp, 0x20), 0) // 4 bytes of `to` and 28 bytes of `amount` were stored here
mstore(add(fmp, 0x40), 0) // 4 bytes of `amount` were stored here
关于 CustomRevert
内的其他函数,都是一些简单的内联汇编代码,读者可以自行阅读。
Unlock 与 Delta
在上文内,我们指出 Uniswap V4 使用了 Flash Accounting 机制,在一笔复杂的 swap 交易内,部分交易可以产生“亏空”,但只要最终可以实现平衡即可。比如在 USDT -> DAI -> USDC 的兑换路径内,我们可以先完成 DAI -> USDC 的兑换,虽然此时我们没有 DAI,但是 Uniswap V4 不会直接 revert 交易。但我们完成整条兑换路径后,Uniswap V4 会检查用户资产的平衡,此时如果没有平衡则会直接报错。
在这里存在一个核心问题,即 Uniswap V4 如何知道一笔交易是否结束。在此处,Uniswap V4 使用了一个特殊的 unlock
函数,该函数代码如下:
function unlock(bytes calldata data) external override returns (bytes memory result) {
if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();
Lock.unlock();
// the caller does everything in this callback, including paying what they owe via calls to settle
result = IUnlockCallback(msg.sender).unlockCallback(data);
if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
Lock.lock();
}
上述函数很简单,当用户调用 unlock
函数时,unlock
函数会解锁合约,然后使用 callback
回调请求合约,当 unlockCallback
结束后,unlock
函数会检查 NonzeroDeltaCount
的内容,如果用户在该笔交易的最后没有实现平衡,就会直接 revert 交易。在 unlock
函数的最后,我们会继续锁定合约。
在 Uniswap V4 合约内,modifyLiquidity
和 swap
等涉及到资金函数都会存在 onlyWhenUnlocked
的修饰函数,所以如果请求合约没有调用 unlock
函数,那么该合约就无法调用其他函数。所以任何与 Uniswap V4 交互的交易都需要首先调用 unlock
函数解锁合约,否则其他的调用都会无效。一旦调用 unlock
函数后,请求合约必须使用 unlockCallback
函数接受回调,请求合约需要在回调内处理 swap
等函数。unlock
函数最终使用 NonzeroDeltaCount.read()
保证交易过程中的平衡。
简单来说,unlock
函数使用 unlock
与 callback
逻辑的组合最终实现了在用户交易的结束检查账户的平衡。事实上,上述逻辑也可以实现防止重入的功能。
接下来,我们具体分析 unlock
内部的具体内容。Lock
是一个使用了 transient storage
进行存储的对象。该部分的代码如下(注意,以下代码位于 BUSL 授权下,不建议读者在自己的商业项目内直接摘抄,后文内出现的 BUSL 代码都会有此警告):
library Lock {
// The slot holding the unlocked state, transiently. bytes32(uint256(keccak256("Unlocked")) - 1)
bytes32 internal constant IS_UNLOCKED_SLOT = 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;
function unlock() internal {
assembly ("memory-safe") {
// unlock
tstore(IS_UNLOCKED_SLOT, true)
}
}
function lock() internal {
assembly ("memory-safe") {
tstore(IS_UNLOCKED_SLOT, false)
}
}
function isUnlocked() internal view returns (bool unlocked) {
assembly ("memory-safe") {
unlocked := tload(IS_UNLOCKED_SLOT)
}
}
}
由于 Solidity 目前还没有高级语言对闪存( transient storage
)的支持,所以此处只能使用 yul
汇编进行管理。此处的 tstore
就是向指定闪存存储槽写入数据,而 tload
则是在指定闪存存储槽内读取数据。此处选择的存储槽是 bytes32(uint256(keccak256("Unlocked")) - 1)
,此处使用 keccak256("Unlocked")) - 1
的原因是为了避免可能的存储槽碰撞。假如读者直接使用 ``bytes32(uint256(keccak256(“Unlocked”)))作为重要变量的存储位置,可能存在攻击者利用
mapping类型通过哈希碰撞攻击的可能性。事实上,大部分自定义存储槽都会使用
bytes32(uint256(keccak256(VAR NAME)) - 1)的形式,此处的
VAR NAME` 就是该存储槽存储的变量名。
然后,我们可以分析 NonzeroDeltaCount
内的内容。NonzeroDeltaCount
内部记录了当前系统内没有被平衡的代币数量。_accountDelta
内部具有 NonzeroDeltaCount
的操作代码:
function _accountDelta(Currency currency, int128 delta, address target) internal {
if (delta == 0) return;
(int256 previous, int256 next) = currency.applyDelta(target, delta);
if (next == 0) {
NonzeroDeltaCount.decrement();
} else if (previous == 0) {
NonzeroDeltaCount.increment();
}
}
当某一个代币账户从零值转化为其他数值时,我们会增加 NonzeroDeltaCount
的数值,反之则减少 NonzeroDeltaCount
的数值。当 NonzeroDeltaCount == 0
时,则说明当前所有的代币都被平衡了。
此处的 applyDelta
函数是比较有趣的,该部分代码位于 src/libraries/CurrencyDelta.sol
内部,注意该部分代码也位于 BUSL 授权下,读者应该谨慎使用。代码如下:
library CurrencyDelta {
/// @notice calculates which storage slot a delta should be stored in for a given account and currency
function _computeSlot(address target, Currency currency) internal pure returns (bytes32 hashSlot) {
assembly ("memory-safe") {
mstore(0, and(target, 0xffffffffffffffffffffffffffffffffffffffff))
mstore(32, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
hashSlot := keccak256(0, 64)
}
}
function getDelta(Currency currency, address target) internal view returns (int256 delta) {
bytes32 hashSlot = _computeSlot(target, currency);
assembly ("memory-safe") {
delta := tload(hashSlot)
}
}
/// @notice applies a new currency delta for a given account and currency
/// @return previous The prior value
/// @return next The modified result
function applyDelta(Currency currency, address target, int128 delta)
internal
returns (int256 previous, int256 next)
{
bytes32 hashSlot = _computeSlot(target, currency);
assembly ("memory-safe") {
previous := tload(hashSlot)
}
next = previous + delta;
assembly ("memory-safe") {
tstore(hashSlot, next)
}
}
}
此处的 applyDelta
内部首先使用 _computeSlot
计算了用户持有的某一个代币的所在闪存地址。此处使用的 _computeSlot
函数实现很有意思。简单来说,我们可以将上述代码转化为 keccak256(target || currency)
,其中 ||
是字节拼接函数(concat
)。此处有读者好奇,此处标记为 memory-safe
,但最终没有清理内存。这是因为 solidity 文档内约定 0x00 - 0x3f (64 bytes): scratch space for hashing methods.
。简单来说 0x00 - 0x3f
这部分内存只是用来进行哈希计算的,所以我们可以不清理内存或者修改 0x40
空闲内存指针。
最后,我们可以简单看一下 NonzeroDeltaCount
内部给出的代码,这部分代码也位于 BUSL 授权下,读者应该谨慎使用:
library NonzeroDeltaCount {
// The slot holding the number of nonzero deltas. bytes32(uint256(keccak256("NonzeroDeltaCount")) - 1)
bytes32 internal constant NONZERO_DELTA_COUNT_SLOT =
0x7d4b3164c6e45b97e7d87b7125a44c5828d005af88f9d751cfd78729c5d99a0b;
function read() internal view returns (uint256 count) {
assembly ("memory-safe") {
count := tload(NONZERO_DELTA_COUNT_SLOT)
}
}
function increment() internal {
assembly ("memory-safe") {
let count := tload(NONZERO_DELTA_COUNT_SLOT)
count := add(count, 1)
tstore(NONZERO_DELTA_COUNT_SLOT, count)
}
}
/// @notice Potential to underflow. Ensure checks are performed by integrating contracts to ensure this does not happen.
/// Current usage ensures this will not happen because we call decrement with known boundaries (only up to the number of times we call increment).
function decrement() internal {
assembly ("memory-safe") {
let count := tload(NONZERO_DELTA_COUNT_SLOT)
count := sub(count, 1)
tstore(NONZERO_DELTA_COUNT_SLOT, count)
}
}
}
以上代码也较为简单,不再赘述。
在 Uniswap v4 内部,还存在一些与 Delta 可以配合使用的函数。第一个是 mint
函数,与 Uniswap v3 不同,Uniswap v4 中的 mint
函数用于 ERC6909 的代币铸造:
function mint(address to, uint256 id, uint256 amount) external onlyWhenUnlocked {
unchecked {
Currency currency = CurrencyLibrary.fromId(id);
// negation must be safe as amount is not negative
_accountDelta(currency, -(amount.toInt128()), msg.sender);
_mint(to, currency.toId(), amount);
}
}
实际上,用户铸造代币时必不需要立即将代币注入到 Uniswap v4 的池子内部,只需要最终平衡账户即可。
与 mint
函数相反,Uniswap v4 主合约内部还包含 burn
函数,该函数主要用于销毁 ERC6909 代币,并且增加用户的可用余额。该函数实现如下:
function burn(address from, uint256 id, uint256 amount) external onlyWhenUnlocked {
Currency currency = CurrencyLibrary.fromId(id);
_accountDelta(currency, amount.toInt128(), msg.sender);
_burnFrom(from, currency.toId(), amount);
}
简单来说,mint
和 burn
函数的功能都是将用户在 Flash Accounting 中的余额转化为 ERC6909 代币。但假如用户在 Flash Accounting
内存在余额,而且不希望使用 mint
函数将余额铸造为 ERC6909 代币,那么可以直接使用 take
函数将余额对应的底层资产直接提取:
function take(Currency currency, address to, uint256 amount) external onlyWhenUnlocked {
unchecked {
// negation must be safe as amount is not negative
_accountDelta(currency, -(amount.toInt128()), msg.sender);
currency.transfer(to, amount);
}
}
相反,假如用户持有某种 ERC20 代币,希望将此代币转化为交易过程中的 Flash Accounting 中的可用余额,那么用户可以调用 settle()
或者 settleFor(address recipient)
函数,两者的区别在于 settle()
函数默认使用 msg.sender
作为 recipient
。该函数定义如下:
/// @inheritdoc IPoolManager
function settleFor(address recipient) external payable onlyWhenUnlocked returns (uint256) {
return _settle(recipient);
}
此处使用了 _settle
内部函数,该函数的定义如下:
function _settle(address recipient) internal returns (uint256 paid) {
Currency currency = CurrencyReserves.getSyncedCurrency();
// if not previously synced, or the syncedCurrency slot has been reset, expects native currency to be settled
if (currency.isAddressZero()) {
paid = msg.value;
} else {
if (msg.value > 0) NonzeroNativeValue.selector.revertWith();
// Reserves are guaranteed to be set because currency and reserves are always set together
uint256 reservesBefore = CurrencyReserves.getSyncedReserves();
uint256 reservesNow = currency.balanceOfSelf();
paid = reservesNow - reservesBefore;
CurrencyReserves.resetCurrency();
}
_accountDelta(currency, paid.toInt128(), recipient);
}
简单来说,该函数根据当前余额与之前代币余额的差值为用户增加账户余额。但可能有读者好奇,Uniswap v4 主合约如何知道为用户的哪种代币增加余额以及如何定义之前的代币余额。这实际上涉及另一个函数 sync
,该函数定义如下:
function sync(Currency currency) external {
// address(0) is used for the native currency
if (currency.isAddressZero()) {
// The reserves balance is not used for native settling, so we only need to reset the currency.
CurrencyReserves.resetCurrency();
} else {
uint256 balance = currency.balanceOfSelf();
CurrencyReserves.syncCurrencyAndReserves(currency, balance);
}
}
实际上当用户调用 settle
之前,用户需要首先调用 sync
函数锁定一些变量,然后向 Uniswap v4 主合约注入资产,最后调用 settle
函数将资产增加到 Flash Accounting 可用余额内部。此处所有的记录都位于闪存内部,如果读者对 resetCurrency
等函数感兴趣,可以自行阅读 src/libraries/CurrencyReserves.sol
内部的代码,注意该部分代码依旧位于 BUSL 授权下。
Hooks
由于 Uniswap v4 的 hooks 具有复杂的权限系统以及相关的校验代码,所以在介绍其他业务逻辑代码前,我们首先介绍一下 Uniswap V4 的 hooks 的权限问题。这部分代码都位于 src/libraries/Hooks.sol
内部。Uniswap V4 的 hooks 的权限可以使用以下结构体汇总:
struct Permissions {
bool beforeInitialize;
bool afterInitialize;
bool beforeAddLiquidity;
bool afterAddLiquidity;
bool beforeRemoveLiquidity;
bool afterRemoveLiquidity;
bool beforeSwap;
bool afterSwap;
bool beforeDonate;
bool afterDonate;
bool beforeSwapReturnDelta;
bool afterSwapReturnDelta;
bool afterAddLiquidityReturnDelta;
bool afterRemoveLiquidityReturnDelta;
}
当上述权限被设置后,当 Uniswap V4 执行到相关部分后就会调用指定的 hooks。如果我们将 beforeInitialize
和 beforeAddLiquidity
设置权限后,那么当资金池初始化前和流动性添加前都会调用我们指定的 hooks。
上述权限分别代表:
beforeInitialize
和afterInitialize
资金池初始化前和初始化后调用beforeAddLiquidity
和afterAddLiquidity
流动性添加前和添加后调用beforeRemoveLiquidity
和afterRemoveLiquidity
流动性移除前和移除后调用beforeSwap
和afterSwap
代币兑换前和兑换后调用beforeDonate
和afterDonate
代币捐赠前和捐赠后调用beforeSwapReturnDelta
和afterSwapReturnDelta
这两个是一个特殊的权限,代表 hook 可以在兑换过程中修改 hook 地址对应的代币Delta
。或者说如果 hook 被赋予这个权限,那么 hook 可以在兑换过程中掌握一部分代币。afterAddLiquidityReturnDelta
和afterRemoveLiquidityReturnDelta
也是特殊权限,代表 hook 可以在流动性添加前和流动性移除后修改 hook 合约的代币Delta
上述 hook 权限内最难以理解的可能就是 beforeSwapReturnDelta
等带有 ReturnDelta
标识的权限。这些权限都是用于修改 hook 在用户调用过程中的 Flash Accounting 系统内 hook 地址所对应的代币 Delta 数量。以下代码给出了在 Uniswap V4 内的 swap
函数内的 hook 调用:
(swapDelta, hookDelta) = key.hooks.afterSwap(key, params, swapDelta, hookData, beforeSwapDelta);
// if the hook doesn't have the flag to be able to return deltas, hookDelta will always be 0
if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));
那么 Uniswap V4 系统如何识别 hook 是否启用了某一个权限?简单来说,Uniswap V4 使用 hook 地址的最后 14 位进行识别。假如我们的 hook 地址的最后 14 bit 的数值为 10 0100 0000 0000
。那么就意味着该 hook 启用了 beforeInitialize
和 afterAddLiquidity
权限。
我们使用 hasPermission
函数检查 hook 是否具有某一个权限。
function hasPermission(IHooks self, uint160 flag) internal pure returns (bool) {
return uint160(address(self)) & flag != 0;
}
此处提供 flag
与地址取并计算地址是否启用了某一个权限。此处的 flag
是一个常量,定义如下:
uint160 internal constant ALL_HOOK_MASK = uint160((1 << 14) - 1);
uint160 internal constant BEFORE_INITIALIZE_FLAG = 1 << 13;
uint160 internal constant AFTER_INITIALIZE_FLAG = 1 << 12;
uint160 internal constant BEFORE_ADD_LIQUIDITY_FLAG = 1 << 11;
uint160 internal constant AFTER_ADD_LIQUIDITY_FLAG = 1 << 10;
uint160 internal constant BEFORE_REMOVE_LIQUIDITY_FLAG = 1 << 9;
uint160 internal constant AFTER_REMOVE_LIQUIDITY_FLAG = 1 << 8;
uint160 internal constant BEFORE_SWAP_FLAG = 1 << 7;
uint160 internal constant AFTER_SWAP_FLAG = 1 << 6;
uint160 internal constant BEFORE_DONATE_FLAG = 1 << 5;
uint160 internal constant AFTER_DONATE_FLAG = 1 << 4;
uint160 internal constant BEFORE_SWAP_RETURNS_DELTA_FLAG = 1 << 3;
uint160 internal constant AFTER_SWAP_RETURNS_DELTA_FLAG = 1 << 2;
uint160 internal constant AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG = 1 << 1;
uint160 internal constant AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG = 1 << 0;
通过以上常量,我们就可以根据 hook 地址推导出该 hook 地址所支持的权限类型。由于目前使用了 14 bit 作为权限位,所以目前想获得一个合适的 hook 地址也需要一定的算力。假如读者真的需要部署 hook 地址,那么可以尝试使用 createXcrunch 工具挖掘地址。该工具可以使用 GPU 算力加速计算。
当用户使用 hook 创建池子时,初始化函数会调用以下函数来校验用户给定的 hook 地址是否正确:
function isValidHookAddress(IHooks self, uint24 fee) internal pure returns (bool) {
// The hook can only have a flag to return a hook delta on an action if it also has the corresponding action flag
if (!self.hasPermission(BEFORE_SWAP_FLAG) && self.hasPermission(BEFORE_SWAP_RETURNS_DELTA_FLAG)) return false;
if (!self.hasPermission(AFTER_SWAP_FLAG) && self.hasPermission(AFTER_SWAP_RETURNS_DELTA_FLAG)) return false;
if (!self.hasPermission(AFTER_ADD_LIQUIDITY_FLAG) && self.hasPermission(AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG))
{
return false;
}
if (
!self.hasPermission(AFTER_REMOVE_LIQUIDITY_FLAG)
&& self.hasPermission(AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG)
) return false;
// If there is no hook contract set, then fee cannot be dynamic
// If a hook contract is set, it must have at least 1 flag set, or have a dynamic fee
return address(self) == address(0)
? !fee.isDynamicFee()
: (uint160(address(self)) & ALL_HOOK_MASK > 0 || fee.isDynamicFee());
}
我们可以通过上述代码看到部分权限间存在依赖关系。依赖关系如下:
- 只有设置
beforeSwap
权限后,才可以设置beforeSwapReturnDelta
权限 - 只有设置
afterSwap
权限后,才可以设置afterSwapReturnDelta
权限 - 只有设置
afterAddLiquidity
权限后,才可以设置afterAddLiquidityReturnDelta
权限 - 只有设置
afterRemoveLiquidity
权限后,才可以设置afterRemoveLiquidityReturnDelta
权限
除此外,假如用户没有设置 hook 地址,那么池子就不可以选择动态费率。或者用户可以选择设置 hook 地址,但设置 hook 地址必须满足以下两个条件之一:
- hook 地址具有至少一个权限
- 启用动态费率
此处可以简单介绍一下动态费率的工作原理:在 Uniswap V4 的核心合约内存在
updateDynamicLPFee
函数,而池子对应的 hook 合约可以调用此函数来更新池子的手续费费率。所以只要池子启用动态费率,那么 hook 地址就被赋予了调整费率的功能
hook 合约需要在指定位置被调用。调用 hook 合约的函数底层使用了 callHook
和 callHookWithReturnDelta
实现。这两个底层函数都使用了 yul 汇编实现。
我们首先介绍 callHook
函数,该函数用于返回一般的 hook 调用的结果:
function callHook(IHooks self, bytes memory data) internal returns (bytes memory result) {
bool success;
assembly ("memory-safe") {
success := call(gas(), self, 0, add(data, 0x20), mload(data), 0, 0)
}
// Revert with FailedHookCall, containing any error message to bubble up
if (!success) CustomRevert.bubbleUpAndRevertWith(address(self), bytes4(data), HookCallFailed.selector);
// The call was successful, fetch the returned data
assembly ("memory-safe") {
// allocate result byte array from the free memory pointer
result := mload(0x40)
// store new free memory pointer at the end of the array padded to 32 bytes
mstore(0x40, add(result, and(add(returndatasize(), 0x3f), not(0x1f))))
// store length in memory
mstore(result, returndatasize())
// copy return data to result
returndatacopy(add(result, 0x20), 0, returndatasize())
}
// Length must be at least 32 to contain the selector. Check expected selector and returned selector match.
if (result.length < 32 || result.parseSelector() != data.parseSelector()) {
InvalidHookResponse.selector.revertWith();
}
}
上述代码使用 call
调用了 hook 合约。这个 call
语句是十分经典的,如果读者阅读过一些其他项目的源代码,就会发现这种 call 语句是十分常见的。call
函数需要以下参数 call(gas, address, value, argsOffset, argsSize, retOffset, retSize
。一般来说,读者可以直接使用 gas()
函数作为 gas
参数传入。gas()
函数会返回当前交易所剩余的 gas 总量。需要注意的,由于 64 规则的存在,EVM 限制了在使用 call
调用外部合约时,最多只会传入 63 / 64
的 gas 数量,所以即使我们手动指定所有 gas 都拿来调用外部合约,实际上还会剩余 1 / 64
的 gas 用以处理报错。
而 argsOffset
和 argsSize
参数用来传入 calldata,前者指定 calldata
的在内存中的开始位置,而后者用于指定 calldata 的长度。此处我们希望使用 bytes memory data
作为 calldata。那么作为 bytes memory
类型,data
在 yul 汇编内会被作为指针处理,指向 data
在内存中的起始位置。另外,作为 bytes memory
类型,data
的最初 32 bytes 的内容实际上是 data
的长度。所以,实际上 data
的起始位置是 add(data, 0x20)
,该位置跳过了最初 32 bytes 的长度信息,而 argSize
则可以直接使用 mload(data)
。mload(data)
会读取 data
的最初 32 bytes 的内容,而此内容刚好对应 data
的长度信息。
在 callHook
函数内,我们将 retOffset
和 retSize
都设置为 0
,这是因为我们不需要 call
帮我们直接将返回值额写入内存,我们会在后文自行处理。
假如 callHook
内部的 call
失败,那么我们会使用 bubbleUpAndRevertWith
函数抛出带有上下文信息的错误。
callHook
内部的后续工作就是将 call
的返回值从 returndata
区域写回内存区域。在这部分中,我们需要注意 uniswap 使用了 memory-safe
编程,所以第一步就是获取空闲的内存指针并且修复该内存指针。
// allocate result byte array from the free memory pointer
result := mload(0x40)
// store new free memory pointer at the end of the array padded to 32 bytes
mstore(0x40, add(result, and(add(returndatasize(), 0x3f), not(0x1f))))
我们首先使用 mload(0x40)
获取当前的空闲内存指针,然后使用 mstore(0x40, add(result, and(add(returndatasize(), 0x3f), not(0x1f))))
指令更新 0x40
部分的空闲内存指针。此处需要特别注意,我们将返回的数据修正为 bytes memory
类型,所以我们需要 returndatasize + 32
,因为我们需要额外的空间存储返回值的长度。同时,我们需要将返回值重整为 32 bytes 倍数的长度。此处使用了 and(x, not(0x1f))
的技巧,使用上述指令可以将 x
重整为 32 的倍数,但需要注意 and(x, not(0x1f))
是一个向下重整的操作,比如 x = 65
,那么 and(x, not(0x1f))
的结果会是 64
,所以为了确保最后获得充分的空间,我们一般使用 and(x + 31, not(0x1f))
来获得最接近 x
的并且比 x
大的数值。需要注意,刚刚我们需要 returndatasize + 32
保证长度信息的存储,此处我们还需要 x + 31
保证重整为 64 的倍数过程中不会出现问题,所以此时就使用了 add(returndatasize(), 0x3f)
。
完成最为复杂的 0x40
修复后,我们使用以下代码将 returndata
写入内存:
// store length in memory
mstore(result, returndatasize())
// copy return data to result
returndatacopy(add(result, 0x20), 0, returndatasize())
最后,我们还要求 hook 的返回值必须存在内容并且必须返回和 data
内一致的选择器。
if (result.length < 32 || result.parseSelector() != data.parseSelector()) {
InvalidHookResponse.selector.revertWith();
}
因为
result
本身就包含 32 bytes 的长度信息,所以此处result.length < 32
成立,即意味着 hook 被调用后没有返回任何数据
此处使用了 parseSelector
函数,该函数较为简单:
function parseSelector(bytes memory result) internal pure returns (bytes4 selector) {
// equivalent: (selector,) = abi.decode(result, (bytes4, int256));
assembly ("memory-safe") {
selector := mload(add(result, 0x20))
}
}
而 callHookWithReturnDelta
函数实际上就是在 callHook
基础上增加了使用 parseReturnDelta
进行返回值的处理的过程。
function callHookWithReturnDelta(IHooks self, bytes memory data, bool parseReturn) internal returns (int256) {
bytes memory result = callHook(self, data);
// If this hook wasn't meant to return something, default to 0 delta
if (!parseReturn) return 0;
// A length of 64 bytes is required to return a bytes4, and a 32 byte delta
if (result.length != 64) InvalidHookResponse.selector.revertWith();
return result.parseReturnDelta();
}
此处使用的 parseReturnDelta
函数定义如下:
function parseReturnDelta(bytes memory result) internal pure returns (int256 hookReturn) {
// equivalent: (, hookReturnDelta) = abi.decode(result, (bytes4, int256));
assembly ("memory-safe") {
hookReturn := mload(add(result, 0x40))
}
}
读者可能还记得 result
在内存内前 32 bytes 代表 result 的长度,接下来 32 bytes 代表 bytes4 selector
,而剩下的内容就是 hookReturn
,所以我们直接 mload(add(result, 0x40))
跳过前 64 bytes 的返回值即可。
最后,我们可以简单看一个高级函数 beforeInitialize
:
function beforeInitialize(IHooks self, PoolKey memory key, uint160 sqrtPriceX96) internal noSelfCall(self) {
if (self.hasPermission(BEFORE_INITIALIZE_FLAG)) {
self.callHook(abi.encodeCall(IHooks.beforeInitialize, (msg.sender, key, sqrtPriceX96)));
}
}
我们可以看到此函数只是对 callHook
的简单封装。此处比较有意思的是 noSelfCall(self)
修饰器,该修饰器的定义如下:
modifier noSelfCall(IHooks self) {
if (msg.sender != address(self)) {
_;
}
}
在该修饰器的作用下,我们会发现 hook 合约是无法再次调用 Uniswap V4 内部可能造成 hook 活动的函数的,以此避免无限的循环调用的出现。
如果读者需要编写 hook 合约,遵循 src/interfaces/IHooks.sol
内的接口编写合约即可,比如 IHook
内部对于 beforeInitialize
的定义如下:
function beforeInitialize(address sender, PoolKey calldata key, uint160 sqrtPriceX96) external returns (bytes4);
注意,此处的 bytes4
应该返回 beforeInitialize
的选择器。Uniswap V4 内的 hook 有一些实现较为简单,而另一些实现由于涉及到变量更新会较为复杂。在后文内,我们将逐一分析每一个 hook 的实现。
afterInitialize
是一个简单的函数,该函数也只是简单的检查权限并使用 callHook
调用 hook 函数。
function afterInitialize(IHooks self, PoolKey memory key, uint160 sqrtPriceX96, int24 tick)
internal
noSelfCall(self)
{
if (self.hasPermission(AFTER_INITIALIZE_FLAG)) {
self.callHook(abi.encodeCall(IHooks.afterInitialize, (msg.sender, key, sqrtPriceX96, tick)));
}
}
beforeModifyLiquidity
是一个较为复杂的函数,但复杂性也较低,该函数主要是根据 params.liquidityDelta
的正负判断当前调用 beforeAddLiquidity
还是 beforeRemoveLiquidity
接口。
function beforeModifyLiquidity(
IHooks self,
PoolKey memory key,
IPoolManager.ModifyLiquidityParams memory params,
bytes calldata hookData
) internal noSelfCall(self) {
if (params.liquidityDelta > 0 && self.hasPermission(BEFORE_ADD_LIQUIDITY_FLAG)) {
self.callHook(abi.encodeCall(IHooks.beforeAddLiquidity, (msg.sender, key, params, hookData)));
} else if (params.liquidityDelta <= 0 && self.hasPermission(BEFORE_REMOVE_LIQUIDITY_FLAG)) {
self.callHook(abi.encodeCall(IHooks.beforeRemoveLiquidity, (msg.sender, key, params, hookData)));
}
}
afterModifyLiquidity
函数是一个较为复杂的函数,注意该函数没有 noSelfCall(self)
的修饰符。但是在函数头部使用 if (msg.sender == address(self)) return (delta, BalanceDeltaLibrary.ZERO_DELTA);
规范了当 hook 递归调用 Uniswap V4 时的操作规范。该函数会返回 callerDelta
和 hookDelta
。afterModifyLiquidity
函数也会根据 params.liquidityDelta
调用不同的 hook。同时会根据 afterAddLiquidityReturnDelta
或者 afterRemoveLiquidityReturnDelta
来决定是否将返回 hookDelta
数值。当 hookDelta
数值返回时,Uniswap V4 主合约就会将部分用户的 Delta 转移给 Hook。
function afterModifyLiquidity(
IHooks self,
PoolKey memory key,
IPoolManager.ModifyLiquidityParams memory params,
BalanceDelta delta,
BalanceDelta feesAccrued,
bytes calldata hookData
) internal returns (BalanceDelta callerDelta, BalanceDelta hookDelta) {
if (msg.sender == address(self)) return (delta, BalanceDeltaLibrary.ZERO_DELTA);
callerDelta = delta;
if (params.liquidityDelta > 0) {
if (self.hasPermission(AFTER_ADD_LIQUIDITY_FLAG)) {
hookDelta = BalanceDelta.wrap(
self.callHookWithReturnDelta(
abi.encodeCall(
IHooks.afterAddLiquidity, (msg.sender, key, params, delta, feesAccrued, hookData)
),
self.hasPermission(AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG)
)
);
callerDelta = callerDelta - hookDelta;
}
} else {
if (self.hasPermission(AFTER_REMOVE_LIQUIDITY_FLAG)) {
hookDelta = BalanceDelta.wrap(
self.callHookWithReturnDelta(
abi.encodeCall(
IHooks.afterRemoveLiquidity, (msg.sender, key, params, delta, feesAccrued, hookData)
),
self.hasPermission(AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG)
)
);
callerDelta = callerDelta - hookDelta;
}
}
}
beforeSwap
也是一个较为复杂的函数,因为在该函数内部设计到了手续费费率更新和用户 swap 数量的修改逻辑。此处需要注意的是,beforeSwap
启用 beforeSwapReturnDelta
权限后可以在用户兑换前修改用户的兑换数量。但兑换数量的正负数值实际上与 exactIn
或 exactOut
模式有关,我们需要保证叠加 hook 的返回结果后,用户的兑换模式不会发生改变。另一个值得注意的地方在于 beforeSwap
会返回 lpFeeOverride
来重载当前的 LP 手续费费率,该参数在用户设置静态费率下会生效。
function beforeSwap(IHooks self, PoolKey memory key, IPoolManager.SwapParams memory params, bytes calldata hookData)
internal
returns (int256 amountToSwap, BeforeSwapDelta hookReturn, uint24 lpFeeOverride)
{
amountToSwap = params.amountSpecified;
if (msg.sender == address(self)) return (amountToSwap, BeforeSwapDeltaLibrary.ZERO_DELTA, lpFeeOverride);
if (self.hasPermission(BEFORE_SWAP_FLAG)) {
bytes memory result = callHook(self, abi.encodeCall(IHooks.beforeSwap, (msg.sender, key, params, hookData)));
// A length of 96 bytes is required to return a bytes4, a 32 byte delta, and an LP fee
if (result.length != 96) InvalidHookResponse.selector.revertWith();
// dynamic fee pools that want to override the cache fee, return a valid fee with the override flag. If override flag
// is set but an invalid fee is returned, the transaction will revert. Otherwise the current LP fee will be used
if (key.fee.isDynamicFee()) lpFeeOverride = result.parseFee();
// skip this logic for the case where the hook return is 0
if (self.hasPermission(BEFORE_SWAP_RETURNS_DELTA_FLAG)) {
hookReturn = BeforeSwapDelta.wrap(result.parseReturnDelta());
// any return in unspecified is passed to the afterSwap hook for handling
int128 hookDeltaSpecified = hookReturn.getSpecifiedDelta();
// Update the swap amount according to the hook's return, and check that the swap type doesn't change (exact input/output)
if (hookDeltaSpecified != 0) {
bool exactInput = amountToSwap < 0;
amountToSwap += hookDeltaSpecified;
if (exactInput ? amountToSwap > 0 : amountToSwap < 0) {
HookDeltaExceedsSwapAmount.selector.revertWith();
}
}
}
}
}
上述函数中有一个较为奇特的函数 getSpecifiedDelta
,该函数的作用是将 int256
转化为 int128
,该函数对应的源代码如下:
function getSpecifiedDelta(BeforeSwapDelta delta) internal pure returns (int128 deltaSpecified) {
assembly ("memory-safe") {
deltaSpecified := sar(128, delta)
}
}
上述函数使用了 sar
操作码,该操作码的作用是将 delta
右移 128 位。注意,getSpecifiedDelta
的目标是在 int256
的高 128 位内获取一个 int128
类型。此处需要补充在 EVM 内部,int
系列类型实际上都使用了补码的形式进行表示。
上述 getSpecifiedDelta
函数被定义在 src/types/BeforeSwapDelta.sol
内部,如果读者查看此文件,会发现与 getSpecifiedDelta
对应另一个 getUnspecifiedDelta
函数,该函数的作用是在一个 int256
的低 128 位内获取一个 int128
。该函数的定义如下:
function getUnspecifiedDelta(BeforeSwapDelta delta) internal pure returns (int128 deltaUnspecified) {
assembly ("memory-safe") {
deltaUnspecified := signextend(15, delta)
}
}
此处使用 signextend(b, x)
方法,这可能是 EVM 内最复杂的操作码。该操作码的执行原理是:b
会指定当前 x
的在哪一个范围内构成反码。比如 0b1111 1110
,该数值可以在认为是一个负数 -2
,但也可以认为是一个正数 254
。这主要取决于我们认为多少位表示一个数字。选择不同的位元,我们可能得到对某一个带符号整数不同的正负结论。在 signextend(b, x)
内,b
就用来确定某一个数字正负情况。假如 b = 0
,那么 x
会被视为 int8
处理;如果 b = 1
,那么 x
被视为 int16
处理。我们认为当我们调用 signextend(0b11111110, 0)
时,此时 signextend
会将 0b11111110
视为负数 -2
,将其扩展为 256 位。而调用 signextend(0b11111110, 1)
时,此时 signextend
会将 0b11111110
视为正数 254。
此处存在一个有趣的情况,即假如 b = 15
,我们认为 x
的类型是一个 int128
,但如果此时 x
为一个大于 int128
的类型,如 int256
的话,此时 signextend
不会处理大于 int128
的部分位元,而是直接将 x
截断为 int128
然后扩展为 int256
。此时我们可以认为 signextend
就是将 int256
的低 128 位在保留原有正负符号的情况下提取为 int128
类型。本质上 signextend(b, x)
就是根据 b
判断 x
所需要扩展的范围,比如 b = 15
,那么 x
的前 128 位需要符号扩展,然后直接将 x
的前 $256 − 8(a + 1)$ 位提取并扩展为带符号的 int256
类型。
我们可以看到 getSpecifiedDelta
在 int256
的高 128 位提取 int128
,而 getUnspecifiedDelta
在 int256
的低 128 位提取 int128
。出现这两个函数的原因在于 Uniswap V4 定义了 BalanceDelta
类型,该类型属于 int256
,但其高 128 位代表 amount0
,低 128 位代表 amount1
。我们在 src/types/BalanceDelta.sol
内部定义了 BalanceDelta
的四则运算和类型转化。
function toBalanceDelta(int128 _amount0, int128 _amount1) pure returns (BalanceDelta balanceDelta) {
assembly ("memory-safe") {
balanceDelta := or(shl(128, _amount0), and(sub(shl(128, 1), 1), _amount1))
}
}
我们使用 toBalanceDelta
将 _amount0
和 _amount1
拼接为 balanceDelta
类型。上述代码内较为复杂的 and(sub(shl(128, 1), 1), _amount1)
是为了清除 _amount1
可能存在的高位垃圾值。对于 BalanceDelta
的加法和减法都较为简单,读者可以自行阅读相关代码。
在上文内,我们可以看到 amountToSwap += hookDeltaSpecified;
的代码,此处的 hookDeltaSpecified
是使用 int128 hookDeltaSpecified = hookReturn.getSpecifiedDelta();
获得的。所以,beforeSwap
返回值的高 128 位用于修改用户 swap 的输入值,但是此处我们并不能确定修改的是哪一个代币的数值,此处可能存在以下四种情况:
// Set amount0 and amount1
// zero for one | exact input |
// true | true | amount 0 = amountToSwap + hookDeltaSpecified
// | | amount 1 = hookDeltaUnspecified
// false | false | amount 0 = amountToSwap + hookDeltaSpecified
// | | amount 1 = hookDeltaUnspecified
// false | true | amount 0 = hookDeltaUnspecified
// | | amount 1 = amountToSwap + hookDeltaSpecified
// true | false | amount 0 = hookDeltaUnspecified
// | | amount 1 = amountToSwap + hookDeltaSpecified
在 afterSwap
内部,我们使用了 BalanceDelta
类型。afterSwap
的源代码如下:
function afterSwap(
IHooks self,
PoolKey memory key,
IPoolManager.SwapParams memory params,
BalanceDelta swapDelta,
bytes calldata hookData,
BeforeSwapDelta beforeSwapHookReturn
) internal returns (BalanceDelta, BalanceDelta) {
if (msg.sender == address(self)) return (swapDelta, BalanceDeltaLibrary.ZERO_DELTA);
int128 hookDeltaSpecified = beforeSwapHookReturn.getSpecifiedDelta();
int128 hookDeltaUnspecified = beforeSwapHookReturn.getUnspecifiedDelta();
if (self.hasPermission(AFTER_SWAP_FLAG)) {
hookDeltaUnspecified += self.callHookWithReturnDelta(
abi.encodeCall(IHooks.afterSwap, (msg.sender, key, params, swapDelta, hookData)),
self.hasPermission(AFTER_SWAP_RETURNS_DELTA_FLAG)
).toInt128();
}
BalanceDelta hookDelta;
if (hookDeltaUnspecified != 0 || hookDeltaSpecified != 0) {
hookDelta = (params.amountSpecified < 0 == params.zeroForOne)
? toBalanceDelta(hookDeltaSpecified, hookDeltaUnspecified)
: toBalanceDelta(hookDeltaUnspecified, hookDeltaSpecified);
// the caller has to pay for (or receive) the hook's delta
swapDelta = swapDelta - hookDelta;
}
return (swapDelta, hookDelta);
}
此处 afterSwap
要求传入 beforeSwap
的返回值 beforeSwapHookReturn
。afterSwap
会在 hook 返回结果后直接修改 beforeSwapHookReturn
内的数值,最后返回 hookDelta
的数值。由于此处需要返回最终的 hookDelta
和 swapDelta
以供 Uniswap v4 合约修改 Flash Accounting 内部记录的数据,所以此处一个较为复杂的逻辑在于构建 hookDelta
。在上文,我们提及 BalanceDelta
是一个特殊的 int256
,其前 128 位代表 amount0
的数值而后 128 位代表 amount1
的数值。所以此处我们需要根据 beforeSwapHookReturn
和 afterSwap
的返回值构造 hookDelta
。根据我们上文给出的表格:
// Set amount0 and amount1
// zero for one | exact input |
// true | true | amount 0 = hookDeltaSpecified
// | | amount 1 = hookDeltaUnspecified
// false | false | amount 0 = hookDeltaSpecified
// | | amount 1 = hookDeltaUnspecified
// false | true | amount 0 = hookDeltaUnspecified
// | | amount 1 = hookDeltaSpecified
// true | false | amount 0 = hookDeltaUnspecified
// | | amount 1 = hookDeltaSpecified
我们可以看到 params.amountSpecified < 0(exactIn) == params.zeroForOne
成立时,我们可以使用 toBalanceDelta(hookDeltaSpecified, hookDeltaUnspecified)
构造 hookDelta
,否则可以使用 toBalanceDelta(hookDeltaUnspecified, hookDeltaSpecified)
构建 hookDelta
。此处的 swapDelta
是 Uniswap V4 主合约构建好的,此处我们不需要关心。
最后,我们总结一下 swap
相关两个函数的作用:
beforeSwap
更新当前的 LP 费率、修改用户的amountToSwap
、修改hookDelta
afterSwap
修改hookDelta
其他的 hook 函数调用在 Uniswap V4 内的代码都较为简单。如下:
function beforeDonate(IHooks self, PoolKey memory key, uint256 amount0, uint256 amount1, bytes calldata hookData)
internal
noSelfCall(self)
{
if (self.hasPermission(BEFORE_DONATE_FLAG)) {
self.callHook(abi.encodeCall(IHooks.beforeDonate, (msg.sender, key, amount0, amount1, hookData)));
}
}
/// @notice calls afterDonate hook if permissioned and validates return value
function afterDonate(IHooks self, PoolKey memory key, uint256 amount0, uint256 amount1, bytes calldata hookData)
internal
noSelfCall(self)
{
if (self.hasPermission(AFTER_DONATE_FLAG)) {
self.callHook(abi.encodeCall(IHooks.afterDonate, (msg.sender, key, amount0, amount1, hookData)));
}
}
总结来看,Uniswap V4 内部的 beforeSwap
和 afterSwap
是最为复杂的 hook 调用,主要原因在于 beforeSwap
和 afterSwap
都是可以直接修改部分内部变量的。对于更加详细的关于 hook 的介绍,我们可能会在后期介绍 hook 合约编程时具体介绍。
初始化
初始化函数是我们阅读 Uniswap v4 的业务逻辑过程中所接触的第一个函数,该函数用于流动性池的初始化。该函数定义如下:
function initialize(PoolKey memory key, uint160 sqrtPriceX96) external noDelegateCall returns (int24 tick) {}
初始化函数需要用户输入 PoolKey
类型的 key
变量,此处的 PoolKey
类型定义如下:
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;
}
用户需要输入 currency0
和 currency1
。但是不同于之前的 Uniswap V3 版本,目前允许用户使用动态费率,只需要将 fee
置为 0x800000
即可,其他情况下仍使用 1000000 = 100%
的精度进行计算。在动态费率下,我们有两种方案可以更新当前的费率。第一种方案是使用 hook 合约,正如上文所述,当 Uniswap V4 主合约调用 beforeSwap
的函数时,会根据 hook
的返回值更新当前的费率。还存在一种情况就是 hook 合约调用 Uniswap V4 主合约中的 updateDynamicLPFee
函数实现费率更新。
与 Uniswap v2 不同,Uniswap V4 内的 tickSpacing
目前则完全放开,用户可以任意选择 1 - 32767 的数值,数值越小,那么价格跳跃越小,但进行 swap 时可能需要更多 gas。而 hooks
参数是指当前流动性池所对应的 hook 合约地址。注意,一个池子只可以指向一个 hook 合约地址。
值得注意的,我们使用上述
PoolKey
可以计算获得一个PoolId
,所以当池子使用不同的 hook 时就会产生不同的PoolKey
在 initialize
函数的入口部分,我们会进行参数的校验:
if (key.tickSpacing > MAX_TICK_SPACING) TickSpacingTooLarge.selector.revertWith(key.tickSpacing);
if (key.tickSpacing < MIN_TICK_SPACING) TickSpacingTooSmall.selector.revertWith(key.tickSpacing);
if (key.currency0 >= key.currency1) {
CurrenciesOutOfOrderOrEqual.selector.revertWith(
Currency.unwrap(key.currency0), Currency.unwrap(key.currency1)
);
}
首先检查 tickSpacing
参数是否位于 MAX_TICK_SPACING
与 MIN_TICK_SPACING
之间。然后校验 currency0
和 currency1
是否满足排序条件。地址较小的代币作为 currency0
,反之作为 currency1
。我们可以看到 Uniswap v4 的特点:Uniswap V4 不在合约内进行各类计算,而是要求用户在本地完成计算,然后在链上只进行计算结果的校验。
我们进行使用以下代码进行 hooks
的校验:
if (!key.hooks.isValidHookAddress(key.fee)) Hooks.HookAddressNotValid.selector.revertWith(address(key.hooks));
最后,我们需要校验 传入参数 key
内的 fee
参数是否正确。我们需要调用如下函数:
uint24 lpFee = key.fee.getInitialLPFee();
此处调用了位于 src/libraries/LPFeeLibrary.sol
内部的 getInitialLPFee
函数,该函数定义如下:
function isValid(uint24 self) internal pure returns (bool) {
return self <= MAX_LP_FEE;
}
function validate(uint24 self) internal pure {
if (!self.isValid()) LPFeeTooLarge.selector.revertWith(self);
}
function getInitialLPFee(uint24 self) internal pure returns (uint24) {
// the initial fee for a dynamic fee pool is 0
if (self.isDynamicFee()) return 0;
self.validate();
return self;
}
上述代码的核心目标是为了避免用户使用大于 100% 的费率,此处的存在常量 uint24 public constant MAX_LP_FEE = 1000000;
完成上述最初始的校验工作后,我们首先调用 key.hooks.beforeInitialize(key, sqrtPriceX96);
代码通知 hook 合约即将初始化流动性池,hook 可以根据 Uni swap v4 传入的参数进行相关响应。接下来,Uniswap v4 主合约会进行一系列初始化操作。第一步就是根据用户输入的 key
计算流动性池的唯一标识 PoolId
。此处会使用 PoolId id = key.toId();
代码,该代码的核心部分就是 toId
函数,该函数定义如下:
library PoolIdLibrary {
/// @notice Returns value equal to keccak256(abi.encode(poolKey))
function toId(PoolKey memory poolKey) internal pure returns (PoolId poolId) {
assembly ("memory-safe") {
// 0xa0 represents the total size of the poolKey struct (5 slots of 32 bytes)
poolId := keccak256(poolKey, 0xa0)
}
}
}
因为 PoolKey
在内存内的布局占用 5 个 uint256
,所以此处进行 keccak256
计算时的长度为 0xa0
。此处可以再次重申,在 yul 汇编内,memory
类型都被视为该数据结构在内存内的起始位置。
最后,我们会进行真正的流动性池的初始化,调用以下代码:
tick = _pools[id].initialize(sqrtPriceX96, lpFee);
_pools
实际上是 mapping(PoolId id => Pool.State) internal _pools;
中定义的变量。我们可以看到在此变量内,我们将流动性池的 PoolId
与流动性池的状态进行了映射。而 Pool.State
的具体定义在 src/libraries/Pool.sol
内部,我们在此处摘录一下 State
的定义代码:
struct State {
Slot0 slot0;
uint256 feeGrowthGlobal0X128;
uint256 feeGrowthGlobal1X128;
uint128 liquidity;
mapping(int24 tick => TickInfo) ticks;
mapping(int16 wordPos => uint256) tickBitmap;
mapping(bytes32 positionKey => Position.State) positions;
}
此处的大部分变量都在 Uniswap v3 的主合约内出现过,我们不再详细介绍其功能。值得一提的是,此处的 Slot0
使用了手动打包的方法是因为使用这种方案可以降低 Slot0
在内存中占用的体积,降低内存占用成本。Slot0
的提取和写入的代码被定义在 src/types/Slot0.sol
内,我们可以简单分析几段代码:
function tick(Slot0 _packed) internal pure returns (int24 _tick) {
assembly ("memory-safe") {
_tick := signextend(2, shr(TICK_OFFSET, _packed))
}
}
function protocolFee(Slot0 _packed) internal pure returns (uint24 _protocolFee) {
assembly ("memory-safe") {
_protocolFee := and(MASK_24_BITS, shr(PROTOCOL_FEE_OFFSET, _packed))
}
}
tick
函数内部的 shr
是为了向右移动 _packed
使得 tick
变量位于最右侧,然后直接使用 signextend
将右移之后的结果转化为 int24
,需要注意这种转化清除其他参数的数值,所以 signextend
的结果就是我们需要的最终结果。
protocolFee
函数则展示了一般的 uint
类型的数值提取,也是先使用 shr
方法将需要的参数移动到最右侧,然后直接使用 MASK_24_BITS
将其他数值使用 and
方法清空。
对于 Slot0
的数值写入,我们使用如下代码:
function setProtocolFee(Slot0 _packed, uint24 _protocolFee) internal pure returns (Slot0 _result) {
assembly ("memory-safe") {
_result :=
or(
and(not(shl(PROTOCOL_FEE_OFFSET, MASK_24_BITS)), _packed),
shl(PROTOCOL_FEE_OFFSET, and(MASK_24_BITS, _protocolFee))
)
}
}
简单来说,我们首先将 MASK 掩码移动到需要修改的参数位置,然后通过取 not
操作将掩码从原有的全 1
构成修改为全 0
构成,然后与 _packed
取 and
操作是的 protocolFee
所在的位置被置为 0
。最后使用 or
将修正好的 _protocolFee
写入。
有了上述知识后,我们就可以完成 initialize
函数,该函数的定义如下:
function initialize(State storage self, uint160 sqrtPriceX96, uint24 lpFee) internal returns (int24 tick) {
if (self.slot0.sqrtPriceX96() != 0) PoolAlreadyInitialized.selector.revertWith();
tick = TickMath.getTickAtSqrtPrice(sqrtPriceX96);
// the initial protocolFee is 0 so doesn't need to be set
self.slot0 = Slot0.wrap(bytes32(0)).setSqrtPriceX96(sqrtPriceX96).setTick(tick).setLpFee(lpFee);
}
此处的 getTickAtSqrtPrice
与 Uniswap v3 内是一致的,读者可以自行阅读之前的博客。
完成上述所有工作后,Uniswap v4 的主合约会使用 key.hooks.afterInitialize(key, sqrtPriceX96, tick);
代码通知 hook 合约已完成初始化,hook 合约可以进行其他操作。
流动性修改
流动性的修改需要调用 modifyLiquidity
函数,该函数的定义如下:
function modifyLiquidity(
PoolKey memory key,
IPoolManager.ModifyLiquidityParams memory params,
bytes calldata hookData
) external onlyWhenUnlocked noDelegateCall returns (BalanceDelta callerDelta, BalanceDelta feesAccrued) {}
此处的 PoolKey
就是需要操作的流动性池的 ID,而 ModifyLiquidityParams
结构体定义如下:
struct ModifyLiquidityParams {
// the lower and upper tick of the position
int24 tickLower;
int24 tickUpper;
// how to modify the liquidity
int256 liquidityDelta;
// a value to set if you want unique liquidity positions at the same range
bytes32 salt;
}
相比于 Uniswap v3,Uniswap v4 使用了 salt
替代了原有的 owner
参数。但是需要注意 owner
参数实际上还存在。我们先分析 modifyLiquidity
的第一部分代码:
PoolId id = key.toId();
{
Pool.State storage pool = _getPool(id);
pool.checkPoolInitialized();
key.hooks.beforeModifyLiquidity(key, params, hookData);
BalanceDelta principalDelta;
(principalDelta, feesAccrued) = pool.modifyLiquidity(
Pool.ModifyLiquidityParams({
owner: msg.sender,
tickLower: params.tickLower,
tickUpper: params.tickUpper,
liquidityDelta: params.liquidityDelta.toInt128(),
tickSpacing: key.tickSpacing,
salt: params.salt
})
);
// fee delta and principal delta are both accrued to the caller
callerDelta = principalDelta + feesAccrued;
}
此处首先将用户输入 key
转化为 PoolId
。然后使用 _getPool
函数获得 Pool.State
,此处使用的 _getPool
函数定义如下:
/// @notice Implementation of the _getPool function defined in ProtocolFees
function _getPool(PoolId id) internal view override returns (Pool.State storage) {
return _pools[id];
}
然后使用 checkPoolInitialized
函数检查 Pool 是否初始化,该函数对应的代码如下:
function checkPoolInitialized(State storage self) internal view {
if (self.slot0.sqrtPriceX96() == 0) PoolNotInitialized.selector.revertWith();
}
然后,我们调用 hook 合约的 beforeModifyLiquidity
函数来通知 hook 合约。
后续的核心步骤是 pool.modifyLiquidity
函数,我们先跳过对该函数内部的具体分析。简单来说,pool.modifyLiquidity
函数会返回当前用户添加流动性所需要的两种代币数量 principalDelta
和可能存在的手续费收入 feesAccrued
。最后统一计算 callerDelta = principalDelta + feesAccrued;
。相比于 Uniswap v3 中的 _modifyPosition
函数,此处将用户修改的流动性和手续费结合实际上方便于用户进行 LP 的复利操作。在 Unsiwap v3 内,用户进行复利操作需要首先调用 burn
函数触发手续费更新,然后调用 collect
函数提取手续费,最后调用 mint
函数添加流动性。但在 Uniswap v4 内,只需要一步调用 modifyLiquidity
函数,只需要保证铸造的流动性数量与手续费数量相等,我们就可以使得 callerDelta = 0
,此时我们不需要想 Uniswap v4 的池子注入代币。
最后,我们将 modifyLiquidity
的最后部分代码展示一下:
// event is emitted before the afterModifyLiquidity call to ensure events are always emitted in order
emit ModifyLiquidity(id, msg.sender, params.tickLower, params.tickUpper, params.liquidityDelta, params.salt);
BalanceDelta hookDelta;
(callerDelta, hookDelta) = key.hooks.afterModifyLiquidity(key, params, callerDelta, feesAccrued, hookData);
// if the hook doesn't have the flag to be able to return deltas, hookDelta will always be 0
if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));
_accountPoolBalanceDelta(key, callerDelta, msg.sender);
这些代码都是处理流动性修改后的收尾工作。首先抛出事件以方便 indexer 检索,然后调用 hook 合约中的 afterModifyLiquidity
函数,并处理可能的 hookDelta
返回值。此处使用了 _accountPoolBalanceDelta
函数进行了 BalanceDelta
的写入,该函数的定义如下:
function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta, address target) internal {
_accountDelta(key.currency0, delta.amount0(), target);
_accountDelta(key.currency1, delta.amount1(), target);
}
完成了上述介绍后,我们具体介绍一下 pool.modifyLiquidity
函数,该函数具有以下几个功能:
- 使用
updateTick
更新 Tick 内部的数据,具体可以参考 Uniswap v3 中的部分 - 使用
position.update
函数更新计算Position
内部的手续费收入 - 计算指定流动性添加所吸引的代币数量以及修改全局流动性
在后文内部,我们不会介绍一些实现与 Uniswap v3 一致的函数,读者可以自行阅读笔者之前编写的 现代 DeFi: Uniswap V3。我们首先分析第一部分代码,该部分代码用于校验输入参数的正确性,代码如下:
int128 liquidityDelta = params.liquidityDelta;
int24 tickLower = params.tickLower;
int24 tickUpper = params.tickUpper;
checkTicks(tickLower, tickUpper);
接下来,modifyLiquidity
完成了 Tick 相关数据的更新。由于 Uniswap V4 内部去掉了主合约内部的预言机逻辑,所以目前 Tick 内只记录了 liquidityGrossAfter
/ liquidityNet
/ feeGrowthOutside0X128
和 feeGrowthOutside1X128
。其中前者 liquidityGrossAfter
用于记录所有引用该 tick 的 Position 的流动性数量,主要功能是为了判断 tick 是否会被 flipped。而 liquidityNet
则记录该 tick 被跨越时流动性的改变数量。剩下的 feeGrowthOutside0X128
和 feeGrowthOutside1X128
用于手续费记录。该部分被定义在 TickInfo
结构体内部:
struct TickInfo {
// the total position liquidity that references this tick
uint128 liquidityGross;
// amount of net liquidity added (subtracted) when tick is crossed from left to right (right to left),
int128 liquidityNet;
// fee growth per unit of liquidity on the _other_ side of this tick (relative to the current tick)
// only has relative meaning, not absolute — the value depends on when the tick is initialized
uint256 feeGrowthOutside0X128;
uint256 feeGrowthOutside1X128;
}
比较有趣的是在 updateTick
内部使用了如下内联汇编实现数据更新:
TickInfo storage info = self.ticks[tick];
assembly ("memory-safe") {
// liquidityGrossAfter and liquidityNet are packed in the first slot of `info`
// So we can store them with a single sstore by packing them ourselves first
sstore(
info.slot,
// bitwise OR to pack liquidityGrossAfter and liquidityNet
or(
// Put liquidityGrossAfter in the lower bits, clearing out the upper bits
and(liquidityGrossAfter, 0xffffffffffffffffffffffffffffffff),
// Shift liquidityNet to put it in the upper bits (no need for signextend since we're shifting left)
shl(128, liquidityNet)
)
)
}
这是因为 TickInfo
的体积为 64 bytes,在 EVM 的存储布局内占用 2 个存储槽。在过去的方案内部,Uniswap v3 选择的方案是将 TickInfo
完全缓存到内存中,在修改完成后,再将内存中的结构体完全写入存储槽,这实际上并不有利于 Gas 优化,一种更好的选择就是上文给出的内联汇编代码。我们由于 TickInfo
占用了两个存储槽,所以更新 liquidityGross
和 liquidityNet
需要确定更新存储槽的位置。基于 solidity 存储打包的规则,结构体内的数据按顺序写入存储槽,所以我们可以直接使用 info.slot
获得 liquidityGross
和 liquidityNet
的位置,然后直接使用 sstore
进行修改。
但是对于
feeGrowthOutside0X128
和feeGrowthOutside1X128
的修改,Uniswap v4 就使用了传统的方案,即直接使用info.feeGrowthOutside0X128 = self.feeGrowthGlobal0X128;
和info.feeGrowthOutside1X128 = self.feeGrowthGlobal1X128;
。这种选择性的优化方案是值得思考的。
updateTick
函数的其他部分与 Uniswap v3 是几乎一致的,读者可以自行阅读一下源代码。
我们展示一下 modifyLiquidity
函数内部对于 Tick 的更新逻辑:
if (liquidityDelta != 0) {
(state.flippedLower, state.liquidityGrossAfterLower) =
updateTick(self, tickLower, liquidityDelta, false);
(state.flippedUpper, state.liquidityGrossAfterUpper) = updateTick(self, tickUpper, liquidityDelta, true);
// `>` and `>=` are logically equivalent here but `>=` is cheaper
if (liquidityDelta >= 0) {
uint128 maxLiquidityPerTick = tickSpacingToMaxLiquidityPerTick(params.tickSpacing);
if (state.liquidityGrossAfterLower > maxLiquidityPerTick) {
TickLiquidityOverflow.selector.revertWith(tickLower);
}
if (state.liquidityGrossAfterUpper > maxLiquidityPerTick) {
TickLiquidityOverflow.selector.revertWith(tickUpper);
}
}
if (state.flippedLower) {
self.tickBitmap.flipTick(tickLower, params.tickSpacing);
}
if (state.flippedUpper) {
self.tickBitmap.flipTick(tickUpper, params.tickSpacing);
}
}
此处使用的 flipTick
相比于 Uniswap v3 而言,已经变成了完全使用内联汇编构建的,但是该部分内联汇编较为简单,读者可以自行阅读。还有一个特殊部分在于 Uniswap v3 在初始化过程中就计算了 maxLiquidityPerTick
变量,在 Uniswap v4 中,我们改成了每次添加流动性时再计算。这可能是因为 Uniswap 开发团队评估了读取 maxLiquidityPerTick
存储槽的成本和每次添加流动性时计算 maxLiquidityPerTick
的成本,发现每次计算可能会更加便宜就选择了每次添加流动性时进行计算。
此处为了避免 solidity 编译器给出堆栈过深的警告。这是因为比如 uint128 maxLiquidityPerTick
这种变量都存储在栈内部,而 EVM 最多只允许 SWAP16
操作码,所以当在单一作用域时给出声明太多变量就会出现堆栈过深的警告。 Uniswap v4 使用了两种方案,第一种就是常规的规划作用域,当 solidity 离开作用域时,作用域内的所有变量都是丢弃。第二种方案就是声明
ModifyLiquidityState memory state;` 变量,利用结构体内部的变量位于内存中的特性避免过多的临时变量占用栈。
在 modifyLiquidity
函数的第二部分,我们需要进行手续费计算,这部分代码基本完全使用了 Uniswap v3 中的代码:
(uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) =
getFeeGrowthInside(self, tickLower, tickUpper);
Position.State storage position = self.positions.get(params.owner, tickLower, tickUpper, params.salt);
(uint256 feesOwed0, uint256 feesOwed1) =
position.update(liquidityDelta, feeGrowthInside0X128, feeGrowthInside1X128);
// Fees earned from LPing are calculated, and returned
feeDelta = toBalanceDelta(feesOwed0.toInt128(), feesOwed1.toInt128());
在 modifyLiquidity
的第三部分,我们需要计算指定流动性添加所吸引的代币数量以及修改全局流动性。这部分代码也与 Uniswap v3 保持了一致性:
if (liquidityDelta != 0) {
Slot0 _slot0 = self.slot0;
(int24 tick, uint160 sqrtPriceX96) = (_slot0.tick(), _slot0.sqrtPriceX96());
if (tick < tickLower) {
// current tick is below the passed range; liquidity can only become in range by crossing from left to
// right, when we'll need _more_ currency0 (it's becoming more valuable) so user must provide it
delta = toBalanceDelta(
SqrtPriceMath.getAmount0Delta(
TickMath.getSqrtPriceAtTick(tickLower), TickMath.getSqrtPriceAtTick(tickUpper), liquidityDelta
).toInt128(),
0
);
} else if (tick < tickUpper) {
delta = toBalanceDelta(
SqrtPriceMath.getAmount0Delta(sqrtPriceX96, TickMath.getSqrtPriceAtTick(tickUpper), liquidityDelta)
.toInt128(),
SqrtPriceMath.getAmount1Delta(TickMath.getSqrtPriceAtTick(tickLower), sqrtPriceX96, liquidityDelta)
.toInt128()
);
self.liquidity = LiquidityMath.addDelta(self.liquidity, liquidityDelta);
} else {
// current tick is above the passed range; liquidity can only become in range by crossing from right to
// left, when we'll need _more_ currency1 (it's becoming more valuable) so user must provide it
delta = toBalanceDelta(
0,
SqrtPriceMath.getAmount1Delta(
TickMath.getSqrtPriceAtTick(tickLower), TickMath.getSqrtPriceAtTick(tickUpper), liquidityDelta
).toInt128()
);
}
}
至此,我们就完成了流动性添加部分的所有代码。
Swap
Uniswap v4 的 swap
函数的相比于 Uniswap v3 在核心的 AMM 数学逻辑上并没有变化,但由于引入了 Hooks 和 Flash Accounting 机制,所以在代码上还有存在一定变化的。我们首先简单看一下 swap
函数的定义:
function swap(PoolKey memory key, IPoolManager.SwapParams memory params, bytes calldata hookData)
external
onlyWhenUnlocked
noDelegateCall
returns (BalanceDelta swapDelta)
{}
此处出现了 IPoolManager.SwapParams
参数,该参数结构体定义如下:
struct SwapParams {
/// Whether to swap token0 for token1 or vice versa
bool zeroForOne;
/// The desired input amount if negative (exactIn), or the desired output amount if positive (exactOut)
int256 amountSpecified;
/// The sqrt price at which, if reached, the swap will stop executing
uint160 sqrtPriceLimitX96;
}
这里需要注意一个有趣的部分,在 Uniswap v3 中,amountSpecified > 0
代表 exactIn
模式,但在 Uniswap v4 内 amountSpecified > 0
则代表 exactOut
模式。这是因为在 Uniswap v4 中,对于某些存在正负数值的变量统一使用用户视角,即资产从用户转移给流动性池,那么代表转移数量的数值应该为负数,反之,如果资产从 Pool 流向用户,那么代表转移数量的数值为正值。比如 exactOut
模式下 amountSpecified
代表用户从 Pool 内获得的代币数量,所以 amountSpecified > 0
。一个更加明显的例子 _accountDelta
内部的一些参数正负问题。比如在 mint
函数,因为 mint
是用户减少个人资产增加 Pool 资产,所以 mint
函数内部使用了负数,即 _accountDelta(currency, -(amount.toInt128()), msg.sender);
在 swap
函数内部,我们第一步仍旧是进行 PoolKey
参数的校验,校验相关代码如下:
if (params.amountSpecified == 0) SwapAmountCannotBeZero.selector.revertWith();
PoolId id = key.toId();
Pool.State storage pool = _getPool(id);
pool.checkPoolInitialized();
之后,我们会调用 beforeSwap
函数通知 hook 合约,然后调用 _swap
的内部函数,该内部函数会返回 swapDelta
,该参数代表当前用户在此次 swap 中造成代币余额变化。然后调用 afterSwap
Hook,最终将 swapDelta
累加到用户的账户内部。
BeforeSwapDelta beforeSwapDelta;
{
int256 amountToSwap;
uint24 lpFeeOverride;
(amountToSwap, beforeSwapDelta, lpFeeOverride) = key.hooks.beforeSwap(key, params, hookData);
// execute swap, account protocol fees, and emit swap event
// _swap is needed to avoid stack too deep error
swapDelta = _swap(
pool,
id,
Pool.SwapParams({
tickSpacing: key.tickSpacing,
zeroForOne: params.zeroForOne,
amountSpecified: amountToSwap,
sqrtPriceLimitX96: params.sqrtPriceLimitX96,
lpFeeOverride: lpFeeOverride
}),
params.zeroForOne ? key.currency0 : key.currency1 // input token
);
}
BalanceDelta hookDelta;
(swapDelta, hookDelta) = key.hooks.afterSwap(key, params, swapDelta, hookData, beforeSwapDelta);
// if the hook doesn't have the flag to be able to return deltas, hookDelta will always be 0
if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));
_accountPoolBalanceDelta(key, swapDelta, msg.sender);
此处比较复杂的是 SwapParams
参数的构造,我们可以看到由于动态费率的引入,构建 SwapParams
时需要手动填写 lpFeeOverride
参数来重载原有的 LP 费率。
上述函数中最重要的就是 _swap
函数,该函数定义如下:
function _swap(Pool.State storage pool, PoolId id, Pool.SwapParams memory params, Currency inputCurrency)
internal
returns (BalanceDelta)
{
(BalanceDelta delta, uint256 amountToProtocol, uint24 swapFee, Pool.SwapResult memory result) =
pool.swap(params);
// the fee is on the input currency
if (amountToProtocol > 0) _updateProtocolFees(inputCurrency, amountToProtocol);
// event is emitted before the afterSwap call to ensure events are always emitted in order
emit Swap(
id,
msg.sender,
delta.amount0(),
delta.amount1(),
result.sqrtPriceX96,
result.liquidity,
result.tick,
swapFee
);
return delta;
}
上述函数的核心部分是 pool.swap
函数。该函数是相当复杂的,但是实际上该函数与 Uniswap v3 中的 swap 逻辑是完全一致的。在上一篇介绍 Uniswap v3 的博客内,我们没有介绍 Protocol Fee 的部分,在本文内,我们会介绍这一部分。注意,在后文内部,我们会跳过与 Uniswap V3 完全相同的代码介绍,只介绍部分 Uniswap v4 中出现的代码。
在 Uniswap V4 的 swap
函数的最初阶段,除了常规的缓存结构体数值填充外,我们可以看到以下两个有趣的手续费逻辑:
uint256 protocolFee =
zeroForOne ? slot0Start.protocolFee().getZeroForOneFee() : slot0Start.protocolFee().getOneForZeroFee();
// if the beforeSwap hook returned a valid fee override, use that as the LP fee, otherwise load from storage
// lpFee, swapFee, and protocolFee are all in pips
{
uint24 lpFee = params.lpFeeOverride.isOverride()
? params.lpFeeOverride.removeOverrideFlagAndValidate()
: slot0Start.lpFee();
swapFee = protocolFee == 0 ? lpFee : uint16(protocolFee).calculateSwapFee(lpFee);
}
其中前者用于获得 protocolFee
的数值,此处使用的 getZeroForOneFee
和 getOneForZeroFee
的定义如下:
function getZeroForOneFee(uint24 self) internal pure returns (uint16) {
return uint16(self & 0xfff);
}
function getOneForZeroFee(uint24 self) internal pure returns (uint16) {
return uint16(self >> 12);
}
该部分代码是技术就是在 uint24
的后 12 位和前 12 位获取手续费数值。
而 lpFee
段则展示假如 hook 在 beforeSwap
内部返回 lpFeeOverride
字段后的处理方案。可以看到实际上假如 hook 返回的 lpFeeOverride
不包含 isOverride
字段,该返回值并不会覆盖当前的 LP 费率。此处使用的 isOverride
的函数定义如下:
uint24 public constant OVERRIDE_FEE_FLAG = 0x400000;
function isOverride(uint24 self) internal pure returns (bool) {
return self & OVERRIDE_FEE_FLAG != 0;
}
假如 hook 返回的手续费费率声明了重载,那么我们就会使用 removeOverrideFlagAndValidate
处理,该函数定义如下:
function removeOverrideFlag(uint24 self) internal pure returns (uint24) {
return self & REMOVE_OVERRIDE_MASK;
}
function removeOverrideFlagAndValidate(uint24 self) internal pure returns (uint24 fee) {
fee = self.removeOverrideFlag();
fee.validate();
}
最后我们会计算整体的手续费情况。假如存在 protocolFee
,那么我们会调用 uint16(protocolFee).calculateSwapFee(lpFee)
进行计算。此处使用的 calculateSwapFee
的定义如下:
// The protocol fee is taken from the input amount first and then the LP fee is taken from the remaining
// The swap fee is capped at 100%
// Equivalent to protocolFee + lpFee(1_000_000 - protocolFee) / 1_000_000 (rounded up)
/// @dev here `self` is just a single direction's protocol fee, not a packed type of 2 protocol fees
function calculateSwapFee(uint16 self, uint24 lpFee) internal pure returns (uint24 swapFee) {
// protocolFee + lpFee - (protocolFee * lpFee / 1_000_000)
assembly ("memory-safe") {
self := and(self, 0xfff)
lpFee := and(lpFee, 0xffffff)
let numerator := mul(self, lpFee)
swapFee := sub(add(self, lpFee), div(numerator, PIPS_DENOMINATOR))
}
}
上述计算出的费率是以 Protocol Fee 收取第一轮,然后由 LP Fee 对剩余部分进行收取费率,所以最终的费率应该为
$$ \begin{align} fee &= protocolFee + lpFee * (100\% - protocolFee) / 100\% \\ &= protocolFee + lpFee - (lpFee * protocolFee) / 100\% \end{align} $$
此处的 100% 就是上文内出现的 uint256 internal constant PIPS_DENOMINATOR = 1_000_000;
。
在计算完成最终的手续费后,我们需要判断该手续费是否合理,所以在 swap
函数内出现了以下代码:
if (swapFee >= SwapMath.MAX_SWAP_FEE) {
// if exactOutput
if (params.amountSpecified > 0) {
InvalidFeeForExactOut.selector.revertWith();
}
}
此处的 MAX_SWAP_FEE = 100%
。当 swapFee
大于 100% 后,我们只能使用 exactOut
模式。因为假如在 swapFee
大于 100% 的情况下选择 exactIn
模式就会出现所有的输入资产都作为手续费的情况。
完成手续费校验后,swap
函数处理了一种特殊情况:
// swapFee is the pool's fee in pips (LP fee + protocol fee)
// when the amount swapped is 0, there is no protocolFee applied and the fee amount paid to the protocol is set to 0
if (params.amountSpecified == 0) return (BalanceDeltaLibrary.ZERO_DELTA, 0, swapFee, result);
即输入的 params.amountSpecified == 0
的情况,此时说明 swap
已经完全完成,用户无需进一步进行 swap
的其他逻辑。由于 Uniswap v4 引入了 beforeSwap
hook 可以修改用户 amountToSwap
变量,所以这种情况是有可能出现的。
接下来,swap
函数检查了用户输入的 sqrtPriceLimitX96
是否正确,此处相比于 Uniswap v3 使用的三目表达式,Uniswap V4 修改为了正常的 If-Else 判断增加了代码可读性。而相比于 Uniswap V3,V4 版本的代码增加了更加有效的错误输出。
if (zeroForOne) {
if (params.sqrtPriceLimitX96 >= slot0Start.sqrtPriceX96()) {
PriceLimitAlreadyExceeded.selector.revertWith(slot0Start.sqrtPriceX96(), params.sqrtPriceLimitX96);
}
// Swaps can never occur at MIN_TICK, only at MIN_TICK + 1, except at initialization of a pool
// Under certain circumstances outlined below, the tick will preemptively reach MIN_TICK without swapping there
if (params.sqrtPriceLimitX96 <= TickMath.MIN_SQRT_PRICE) {
PriceLimitOutOfBounds.selector.revertWith(params.sqrtPriceLimitX96);
}
} else {
if (params.sqrtPriceLimitX96 <= slot0Start.sqrtPriceX96()) {
PriceLimitAlreadyExceeded.selector.revertWith(slot0Start.sqrtPriceX96(), params.sqrtPriceLimitX96);
}
if (params.sqrtPriceLimitX96 >= TickMath.MAX_SQRT_PRICE) {
PriceLimitOutOfBounds.selector.revertWith(params.sqrtPriceLimitX96);
}
}
上述代码在 Uniswap V3 内表述为:
require(
zeroForOne
? sqrtPriceLimitX96 < slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 > TickMath.MIN_SQRT_RATIO
: sqrtPriceLimitX96 > slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 < TickMath.MAX_SQRT_RATIO,
'SPL'
);
在 swap
的核心计算过程中,大部分代码都与 Uniswap v3 相等,但部分代码进行了优化,比如在 computeSwapStep
函数传参过程中,我们使用了 SwapMath.getSqrtPriceTarget
函数获得 PriceTarget
。SwapMath.getSqrtPriceTarget
是一个无分枝的函数,优化了 gas。
在计算 protocolFee
时,也使用了更加优化的方案,代码如下:
if (protocolFee > 0) {
unchecked {
// step.amountIn does not include the swap fee, as it's already been taken from it,
// so add it back to get the total amountIn and use that to calculate the amount of fees owed to the protocol
// cannot overflow due to limits on the size of protocolFee and params.amountSpecified
// this rounds down to favor LPs over the protocol
uint256 delta = (swapFee == protocolFee)
? step.feeAmount // lp fee is 0, so the entire fee is owed to the protocol instead
: (step.amountIn + step.feeAmount) * protocolFee / ProtocolFeeLibrary.PIPS_DENOMINATOR;
// subtract it from the total fee and add it to the protocol fee
step.feeAmount -= delta;
amountToProtocol += delta;
}
}
Uniswap V4 中的 swap
函数在最后增加了结果向 BalanceDelta
类型的转化,代码如下:
unchecked {
// "if currency1 is specified"
if (zeroForOne != (params.amountSpecified < 0)) {
swapDelta = toBalanceDelta(
amountCalculated.toInt128(), (params.amountSpecified - amountSpecifiedRemaining).toInt128()
);
} else {
swapDelta = toBalanceDelta(
(params.amountSpecified - amountSpecifiedRemaining).toInt128(), amountCalculated.toInt128()
);
}
}
这部分本质上就是对 Uniswap V3 中计算 amount0
和 amount1
的另一个版本:
(amount0, amount1) = zeroForOne == exactInput
? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated)
: (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining);
关于 swap 的其他部分基本与 Uniswap v3 是一致的,读者可以自行阅读相关代码。
总结
相比于 Uniswap V3 而言,Uniswap V4 并没有修改 AMM 的逻辑,swap
的逻辑几乎与 Uniswap V3 完全一致,但是相比于 Uniswap V3,Uniswap V4 增加了大量细微的优化调整。这些优化调整的核心部分包括:
- ERC6909 机制使得 Uniswap V4 内部可以直接存储代币降低 transfer 成本
- ERC7751 带来的新的错误抛出,使得开发者和用户可以在不使用额外工具下定位发生问题的合约
- Flash Accounting 带来的更加方便的最终结算,用户可以一次性完成多笔 swap 并在最后完成统一结算
- Hooks 带来的无限的开发想象,Hooks 内的
beforeSwap
和afterSwap
可以直接修改部分内部变量,赋予了应用更大的空间 - Singleton Pool 降低了链式 swap 时的 gas 消耗
- 大量使用内联汇编进行 gas 优化,可以看到很多操作相比于 Uniswap v3 有了更加优化的汇编方案