向 AMM 添加流动性意味着将代币存入 AMM 池。流动性提供者(LP)这样做的目的是希望从与该池进行兑换(swap)的用户那里赚取手续费。
在 Uniswap v2 中,当 LP 添加流动性时,他们会以流动性提供者代币(LP tokens)的形式获得池子的份额,这代表了他们有权获得的池中代币的百分比,包括手续费。这些 LP tokens 是同质化的 ERC-20 代币。
在 Uniswap v3 中,这种方法行不通,因为 LP 会选择他们想要存入流动性的区间(range)——即 lower tick 和 upper tick。因此,协议需要以非同质化的方式单独跟踪每笔存款。这就引出了 positions(头寸)的概念。
当 LP 在某个区间内添加流动性时,我们称之为开启(open)或修改(modify)了一个 position。当该 position 尚不存在时,称为开启;当其已经存在时,称为修改。
一个区间由两个 tick 组成——一个 lower tick 和一个 upper tick。将流动性存入一个区间意味着增加该区间内的真实储备(real reserves)。这是通过使用 UniswapV3Pool.sol 中的 mint 函数来实现的,其接口如下所示。
// UniswapV3Pool.sol
function mint(
address recipient, // the owner of the position
int24 tickLower,
int24 tickUpper,
uint128 amount, // amount in liquidity
bytes calldata data // will be explained in a future chapter, it is not necessary for our discussion
) external override lock returns (uint256 amount0, uint256 amount1) {
该函数被命名为 mint,让人联想到 Uniswap v2,在 v2 中,添加流动性会为流动性提供者铸造(mint)ERC-20 代币。尽管这在 v3 中已不再发生,但这个名称被保留了下来——这一次是指铸造一个 position。
本章的目标是探讨协议是如何以及在哪里存储有关这些 positions 的信息的。
positions 映射
Positions 存储在一个名为 positions 的映射(mapping)中,该映射位于 UniswapV3Pool 合约内,如下图所示。

用于标识 position 的键(key)是由 position 所有者的地址、lower tick 和 upper tick 共同计算出的 Keccak 哈希值构成的。
因此,如果这是所有者 0xA 首次在 tick -10 和 10 之间存入流动性,系统将使用键 keccak(0xA, -10, 10) 创建一个 position。
下面是用于生成 position 键的 Solidity 代码。
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Position {
function getKey(
address owner,
int24 tickLower,
int24 tickUpper)
public pure returns (bytes32 key) {
key = keccak256(abi.encodePacked(owner, tickLower, tickUpper));
}
}
创建后,如果所有者 0xA 在 tick -10 和 10 之间存入(或提取)了更多流动性,该 position 将被修改,因为它已经存在。
该映射的值类型为 Position.Info,这是一个名为 Info 的结构体(struct),位于 Position.sol 合约中的 Position 库内。
该结构体如下所示,其中存储了 position 的 liquidity(橙色框),以及与该 position 拥有的手续费和代币相关的其他四个字段(绿色框)。我们将把对这些其他字段的讨论推迟到介绍手续费和从 position 提取流动性时再进行。

现在,我们可以这样理解:如果特定所有者在某个区间内添加了流动性,就会创建一个 position。此后,可以通过添加或移除流动性来修改该 position。
Positions 是非同质化的
在 Uniswap v2 中,当 LP 提供流动性时,会为其铸造 ERC-20 LP tokens。这些 LP tokens 是同质化的,代表池中资产的份额。这意味着,如果两个 LP 各自拥有 1000 个 LP tokens 并使用这些 LP tokens 赎回他们在池中的份额,他们收到的资产代币将是完全相同的。
在 Uniswap v3 中,positions 是非同质化的。这意味着,如果两个 LP 拥有不同的 positions 并使用这些 positions 赎回他们在池中的份额,他们很可能不会收到相同数量的资产,因为他们所代表的 tick 区间或 LP 在这些 tick 之间贡献的真实储备很可能是不同的。
我们可以通过周边合约(peripheral contract)将这些非同质化的 positions 作为 ERC-721 非同质化代币(NFT)来处理,但核心合约本身并不具备这个功能——它不是 ERC-721 合约,也不允许将 positions 转移给第三方。核心合约仅允许开启和修改 positions。
Uniswap v3 中的手续费
Uniswap v3 中的手续费机制也与 Uniswap v2 大不相同。在 Uniswap v2 中,每当发生兑换(swap)时,手续费都会被添加到池子中,从而增加每份份额有权获得的代币数量。
在 Uniswap v3 中,一个 position 仅有权获得在其 tick 区间内发生(部分或全部发生)的 swap 所产生的手续费。因此,如果 LP 开启了一个从未参与过 swap 的 position,该 position 就不会赚取任何手续费。这种机制激励了 LP 在最有可能发生 swap 的区域开启 positions,从而在最需要流动性的地方增加流动性。
由于手续费不再在所有 LP 之间按比例分配,而是单独归属于每个 position,因此必须将它们分开跟踪。
协议如何计算一个 position 有权获得多少手续费并不直观,它涉及几个变量的组合,包括位于 Position 的 Info 结构体中的 feeGrowthInside0LastX128 和 feeGrowthInside1LastX128 变量。
作为高层次的解释,协议会跟踪自池子创建以来积累的手续费,以及在作为 position 边界的每个 tick 之上和之下收集了多少手续费。
这使得协议能够计算出在一个 position 的 lower tick 和 upper tick 之间收集了多少累积的手续费,并使用该 position 的 feeGrowthInside0LastX128 和 feeGrowthInside1LastX128 变量来确定这些手续费中有多少属于那个特定的 position。
关于协议如何实现这一点的细节将在后续章节中讨论。
一个池子由多个 positions 组成
我们之前一直说池子由线段(segments)组成,因此我们需要将 segments 和 positions 的概念联系起来。它们并不是一回事,因为 positions 是可以重叠的。让我们通过一个例子来探讨这一点。
假设只有两个 positions:
- 在 tick -10 和 5 之间,流动性为 200,如下方红框所示。
- 在 tick 0 和 10 之间,流动性为 100,如下方蓝框所示。

- 在 tick -10 和 0 之间,只有一个流动性为 200 的 position,因此该 segment 的流动性将为 200。
- 在 tick 0 和 5 之间,有两个 positions 重叠:一个流动性为 200,另一个流动性为 100,因此该 segment 的流动性将为 300。
- 在 tick 5 和 15 之间,只有一个流动性为 100 的 position,因此该 segment 的流动性将为 100。
上图右侧展示了这些 segments。
下方是一个交互式工具,读者可以创建多个 positions,该工具会自动基于这些 positions 计算 segments。还可以更改当前价格,并查看当前价格所在 segment 的流动性。
然而,协议并不会像我们刚才那样基于 positions 去计算所有的 segments。这将会非常低效,因为协议不需要这样一幅全局视角。
在后续章节中,我们将讨论协议如何高效地处理 positions 以计算 segments。现在,我们暂且将该计算过程视为一个黑盒。
在下一节中,我们将深入了解 mint 函数,以及 LP 必须存入什么才能开启一个 position。
mint 函数
要向池中添加流动性,就必须存入代币。正如我们所见,这是通过 mint 函数完成的,其接口再次展示如下。
function mint(
address recipient, // the owner of the position
int24 tickLower,
int24 tickUpper,
uint128 amount, // amount in liquidity
bytes calldata data // will be explained in a future chapter, it is not necessary for our discussion
) external override lock returns (uint256 amount0, uint256 amount1) {
该函数预期接收五个参数:
- position 所有者的地址(
recipient),以及该 position 的 lower tick(tickLower)和 upper tick(tickUpper)。 data参数,类型为bytes,将在稍后解释,对当前的讨论不重要。amount参数,即 LP 想要在 lower tick 和 upper tick 之间向池中添加的流动性数量。
请注意,amount 是 uint128 类型,这意味着它不能为负数。mint 函数仅用于添加流动性,而不用于移除流动性——移除流动性由 burn 函数处理,我们将在稍后讨论。
随后,要在 lower tick 和 upper tick 之间添加此 amount 数量的流动性,所需存入的代币数量必须由 mint 函数计算得出。
这就是我们接下来要看的内容。
开启 position 所需的代币
在 lower tick 和 upper tick 之间,与流动性 相对应的代币数量,就是该 position 所对应 segment 的真实储备,即在这个 lower tick 和 upper tick 之间,流动性为 的 segment 的真实储备。
我们已经了解到,一个 segment 的真实储备不仅取决于 tick 边界和流动性 ,还取决于当前价格。规则如下:
- 当当前价格等于或高于 upper tick 时,该 segment 仅具有代币 Y 的真实储备。
- 当当前价格等于或低于 lower tick 时,该 segment 仅具有代币 X 的真实储备。
- 当当前价格位于 lower tick 和 upper tick 之间时,该 segment 同时具有代币 X 和代币 Y 的真实储备。
下图说明了这三种场景,其中红线代表当前价格 , 代表 lower tick, 代表 upper tick。

如果 LP 想要在 lower tick 和 upper tick 之间添加流动性 ,协议将根据上述三种场景计算 (代币 X 的真实储备)和 (代币 Y 的真实储备)。
- 对于场景 1,代币 Y 的真实储备为
- 对于场景 2,代币 X 的真实储备为
- 对于场景 3,代币 X 和代币 Y 的真实储备为
向该 position 添加流动性 所需的这些代币数量,由 mint 函数计算并返回,如下方红框所示。

然而,对于最终用户来说,流动性可能是一个高度抽象的概念。最终用户更习惯于以代币而不是流动性的方式来思考——例如,用户可能希望通过存入 100 个代币 X 来提供流动性,而完全不知道 100 个代币 X 代表多少流动性。
通过选择代币数量来添加流动性,可以借助一个中介合约来简化,该合约作为最终用户与核心合约之间的桥梁,并计算代币数量与流动性之间的转换。
通过 Position Manager 开启 position
核心合约中的 mint 函数旨在被其他合约调用,而不是被 EOA 调用。
当 mint 函数被调用时,它会通过 uniswapV3MintCallback 函数回调触发它的地址,正如我们可以在下方的 mint 函数代码片段(红框高亮处)中看到的那样。

调用 mint 函数的地址必须实现 uniswapV3MintCallback 函数,因为此时调用者必须转入修改 position 所需的代币数量。mint 函数会在调用 uniswapV3MintCallback 之前和之后立即检查池子的余额,并计算差额。
这就是为什么 mint 函数不能被 EOA 调用的原因:EOA 无法响应回调以转入所需的代币数量。因此,如果要求 LP 转入任何代币,EOA 对 mint 函数的调用将会发生 revert,而这在每次调用时都会发生(不允许通过添加零流动性来修改 position)。
交易流程如下图所示,假设调用 mint 函数的合约名为 Position Manager。

核心合约对用户并不友好。通常来说,LP 希望选择他们愿意存入的代币 X 和/或代币 Y 的数量来开启一个 position,而不是直接指定流动性。中介合约的作用是提供一个用户友好的接口,用户只需选择代币数量,随后由 Position Manager 将其转换为与该数量对应的流动性。
该过程的演示如下所示。最终用户(EOA)调用中介合约(Position Manager)中的 mint 函数,将他们想要转化为流动性的代币数量作为参数之一传入。然后,Position Manager 将该代币数量转换为流动性,并通过调用核心合约中的 mint 函数为最终用户开启一个 position。

在本书中,我们不会深入探讨 Position Manager 的具体工作细节。Uniswap 在其 periphery library 中提供了一个用作 Position Manager 的合约,名为 NonfungiblePositionManager,该库所在的仓库与 core library 并不相同。
总结
- 在 Uniswap v3 中添加流动性意味着开启或修改一个 position。Positions 是由其所有者地址以及 lower tick 和 upper tick 来定义的。
- Positions 存储在一个名为
positions的映射中,其键是由所有者地址以及 lower tick 和 upper tick 的 Keccak 哈希值组成的,其值是一个位于Position库中的结构体。 - 要开启 position 或向其添加流动性,需要使用
mint函数。该函数并不用户友好,它接收的参数是用户想要在 lower tick 和 upper tick 之间存入的流动性数量。 mint函数必须由其他合约调用,而不能由 EOA 调用。这些中介合约在 EOA 和核心合约之间发挥作用,它们的其中一个功能可能是将 LP 定义的代币数量转换为核心合约mint函数所期望的流动性数量。- 要在 lower tick 和 upper tick 之间存入流动性 ,就必须存入与这些 tick 之间流动性为 的 segment 的真实储备相对应的代币数量。