Uniswap Crypto
排版说明:正文、数字、公式和原有代码示例保留;新增标题、阅读提示与源码链接。原稿中的表格边框、缩进、断行和列表编号已统一排版。
阅读方式:先顺着普通章节阅读;标为“实现细节(可跳过)”的子标题默认折叠,在 Emacs Org 中将光标放到标题上按 TAB 展开或收起。
代码阅读说明:链接均指向 Uniswap V3 官方仓库的固定提交与行号。原稿中的函数签名、带中文注释的接口和 RMB / HKD 路径表达式属于节选或示例,不是独立可编译的完整合约;完整实现见相邻链接。
写在前面
看到rh链的组lp那么火热,作为一个从很早就开始开发solidity的人而言,我很想试试能不能 通俗 但是又有深度的将它的运作逻辑讲清楚, 向传融金融或者圈外人讲明白,为什么自动做市商(Automated Market Maker)被称作一个伟大的发明。
全文手敲,看了我多年前的内部分享笔记,跟着我从一个开发者的角度来了解一下它是什么样子吧
从区块链到智能合约
code is law ,区块链是从用历史数据不断计算hash来增长的一个数据库,就像一辆开往未来的火车,每笔链上的转账,以及合约调用数据都会不断的填充车厢(transactions),每个车厢会放容量内的数据(data),或多或少。 每一个车厢就是区块链中的区块(block), 很多个区块构成了这条公链(chain)。开往未来的火车,车厢内的东西只能存,不可以修改,每一个新的车厢在创建的时候会把之前的车厢数据记录起来(hash),如果修改了任何一个数据,然后和当前车厢一一起追加到车尾,如果修改了前面的任何数据,那么从修改的这条车厢开始,所有的车厢数据都会发生改变。
车厢中多种乘客(address),我们的钱包叫作eoa账户,而还有一种地址是使用一些代码生成的,这些代码的具体字符,以及创建合约的用户地址和对应的nonce 来决定了这个 合约地址,这个合约地址就是大家口中所说的 智能合约(smart contract) 。在创建后,合约地址和代码内容就会固定死了,合约的代码就是一段逻辑,而合约开源后这段逻辑每个人都可以看到,比如你的智能合约的代码是一个计算的合约,输入两个数字 返回 a+b, 那么在任何时候,你调用这个地址的函数,使用两个值请求就都会返回a+b的结果,这就是code is law 。
我们的amm 就是从这种 部署好的合约 开始的。 为了避免大而全,我们这次只讨论最经典的uniswap ,以uniswap v3 为基础,一直讲到v4 结束。说清楚
- 价格是怎么来的,最简单的amm 是什么样子的。
- 一个池子如何诞生的
- 如何添加流动性,什么是集中流动性,tick到底是什么。
- 一笔swap是如何穿过合约的
- lp的手续费是那里来的
- 移除流动性和领取手续费
- 价格变化后,LP 仓位会变成什么
- 链上应该观察什么
- 打狗的手续费都有什么?
- v4 添加了什么,改变了什么?
价格如何形成:恒定乘积 AMM
在传统金融中我们使用长桥等券商来进行交易,券商会统筹市场上的买单(bid side)和卖单(ask side)来进行撮合(match orders). 将你的法币换成对应的股票,链上也可以使用订单簿的模式,但是 Uniswap 选择了另外一条路,它不要求每一笔交易都有恰好的另一半,而是将资产两两组合成流动性池(Liquity Pool),假设链上已经有人创建了一个 (USDT-TSLA)的流动性池,池中放着(1000USDT 和 1 TSLA),暂时忽略手续费,此时TSLA的价格就是 usdt/tsla = 1000usdt . 算什么标地就把它当作分母。
池子会维持一个准则 x * y = k , x 和 y 是池子中两个货币的储备量,对应到例子中就是1000 和 1,k就是两个的乘积,在买卖的时候有人用usdt买走tsla,池子中usdt会增加,tsla会减少。 TSLA 的池内价格随之升高 物以稀为贵,反之亦然,k在交易中是永远不会变化的,所以它被叫作 恒定乘积AMM。
一个池子如何诞生
代码出处:
那么如果我想交易的币种没有池子呢? 在dapp中池子由工厂合约(factory)创建,叫 CreatePool , 相同的factor下(你也可以部署自己的factory),token0 和 token1 + fee 就是一个固定的合约,(token0 代表地址小的,所以A/B 和 B/A 是一组)你可以使用getPool函数来查询该池子是否已经存在。如果不存在的时候,我们就可以使用CreatePool来做第一个创建池子的人。
fee的参数是一个映射,在uniswap最初有3个默认档位分别是 500 , 3000 ,10000 对应的手续费比例为万5,千3 和百1,如果需要添加映射,就需要factory的所有者地址,在eth上这个所有者地址也是一个合约,我们把它叫治理合约(Governance),它通过多数人的投票运行。 创建池子只是一次简单的登记,相当于你调用了factory ,借它只手发了一个合约,随后你可以来指定你代币的初始价格。
实现细节(可跳过):初始价格、sqrtPriceX96 与代币精度
代码出处:
在合约中有一个sqrtPriceX96的值,它的意思是 价格的平方根 * 2^96 ,首先我们要定义一下原始的单位价格,原始的 Price 是 amount1/amount0 ,那sqrtPriceX96=sqrt(Price) * 2^96 ,为什么这样定义,因为合约没有浮点数,并且这样做可以让计算简单一些,提高gas的效率。, 我们一般会使用encodeSqrtRatioX96(amount1Raw, amount0Raw) 来获取它的价格,amount1Raw代表的是他的最小单位,比如usdt是6位,但是weth就要18位。
Price 永远都只是两个标地的相对价格而已,而描述中P代表的是token0用token1计价的价格,因为是(amount1/amount0)。
集中流动性与 tick
而uniswap v3 相对于v2而言,最大的改动就是引入了tick 的概念。 想象一下你是一个做市商, 或者再通俗一些,你是一个 找换店 的老板,你每天的工作提供RMB 和 HKD 的兑换,你不看外面的汇率,只根据店里两个币种的库存比例报价。 假设开门的时候 柜台里面有10w HKD 和 9万RMB 你的报价就是 1HKD : 0.9 RMB 。但是这个报价是会变化的,,每次兑换前后,港币库存 × 人民币库存,保持不变 ,当你的某个储备金越少,它就卖的越贵。
假设现在来了第一位客人要买走1万港币,应该给多少? 按照我们日常的经验应该是9000RMB哦,我们日常的汇率是全世界联通的,流动性很充足, 但是很反直觉的是,当流动性不充足的时候,按照刚刚的规矩,兑换前 100,000 港币 × 90,000 人民币 。兑换后90,000 港币 × 100,000 人民币 才可以保持库存乘积不变, 所以它需要10000RMB才可以换1W 港币。
这就是交易的价格冲击:一笔兑换本身,就把价格推着沿着这个 曲线变化了。如果客人继续买港币,你就只能继续涨价,反过来,客人不断拿港币换走人民币,港币库存增加,港币报价就一路往下走。这就是uniswap v2 的规则,一套规则从低价用到高价,没有事先设定在哪个价位停止。这套规则决定了 某一段价格内,你只能释放部分资金。因为资金是平均分到所有价格的。开店一段时间你发现 大部分的人的兑换都是在0.9附近,所以你想改变一下你的兑换规则,你的10W HKD 和 9W RMB 都只做0.89 到 0.91 人民币 这一段价格的生意,涨到0.91的时候这笔资金内的港币可以全部卖空,跌倒0.89时候人民币可以全部用来买港币。
这样的话,同样的资金你就可以承载更多的交易在0.9附近,客人的兑换对价钱的影响会变小,可以说你在某一段价格内的深度变大了,而且你也不需要像 uniswap v2 一样投入那么多的本金。那 tick 的作用就是让 智能合约 知道你的这笔资金想要做那个价格段的生意,你可以设定两个边界tick,系统就知道你的资金从那里开始参与,那里停止。代价就是当行情越过你的标记tick的时候,你的资金会变成单边资产,区间内更能做生意,区间外完全不参与任何交易。
tick 如何划分价格
那么 tick 是怎么算的呢? 我们在上面说过,价格的表现在合约中表现是 sqrtPriceX96,代表的是 sqrt(amount1/amount0) * 2^96。 而tick是一个乘法刻度,将价格分为很多个格子, tick每增加一格,价格就乘以 1.0001 ,也就是上涨 0.01% ,协议把价格1 定位第0格的tick , 我们上文的例子中如果老板开门时候 价格是 0.9 。
那么在0.9附近 就有 -1054,-1053 这两条tick .
实现细节(可跳过):TickMath 换算与 bitmap 存储
代码出处:
- TickMath.sol:getSqrtRatioAtTick / getTickAtSqrtRatio
- TickBitmap.sol:flipTick / nextInitializedTickWithinOneWord
在solidity 中有一个TickMath.sol 就是负责来做这些换算的,getSqrtRatioAtTick getTickAtSqrtRatio 你可以随意的将sqrtpricex96 和 tick 互相转换,需要显示价格就再price = (sqrtPriceX96 / 2^96)^2 。 tick就是这样计算的,在合约中它是使用bitmap存储的,一长串的二进制来存储足够多的tick 1代表这个tick有被设置, 0 代表没有有流动性的仓位以它作为边界。 。
一笔 swap 如何穿过合约
那么对于用户而言,一次兑换(Swap) 是如何进行的 ? 在合约的UniswapV3Pool.swap()内所有的swap都是从这个进行。
实现细节(可跳过):swap 参数与多池路径
代码出处:
function swap(
address recipient,
bool zeroForOne,
int256 amountSpecified,
uint160 sqrtPriceLimitX96,
bytes calldata data
) external override noDelegateCall returns (int256 amount0, int256 amount1)
其中ZeroForOne 的意思是 从token0到token1 那么就传true,这个就可以决定币种的方向,付什么,收到什么。 amountSpecified 是来指定数量的,正数代表指定投入的币种数量,负数代表指定想收到的币种的数量。sqrtPriceLimitX96 就是当价格到达此位置的时候就停止,比如开始的价格HKD:RMB是0.9,你不断的买入,港币价格一直上涨,你可以设定最多走到0.91的时候,我就不买了。
那你就可以填入sqrt(0.91) × 2^96 。 data 的参数是一个bytes .在周边合约(v3-periphery)里面多个池子的兑换就是使用data 来做的。想想一下你想使用 RMB 换 USD , 但是在uniswap的池子中有两个方式,一个是直接使用RMB 换 USD ,一个是使用RMB 换 HKD 再由HKD 换 USD ,周边合约就会将这个路径(path) 编码成bytes 。
abi.encodePacked(RMB,fee,HKD,fee,USD)
. 因为v3 区分fee , 两个地址和fee 就可以有唯一的一个池子。 所以币种中间的fee 就是池子的费率。 这个data 还有其他的用法, 比如授权其他用户付款,闪电贷等,闪电贷后面我们应该要讲一下,也是一个很好玩的用法。
价格为什么分段推进
代码出处:
在下单后,订单到底是如何成交的呢? 价格是如何推进的呢? 前文说过 swap 有固定输入数量,固定输出数量的区别,他们都使用了相同的逻辑就是看指定的量有没有被消耗完, 代码中使用 state.amountSpecifiedRemaining 来判断,当一笔常见的兑换会在指定输入,或者指定输出凑齐的 时候停止,没有消耗完就会继续循环,如果在兑换的图中价格到达限制也会提前停止。为什么要循环不一次兑换完呢? 因为在用户添加流动性的时候会选tick , 你的兑换跨越tick 后,不同的tick之间的流动性总量(L)会不同。
比如在0.8到0.9 价格附近 张三添加了流动性, 0.8到 1.0 之间 李四添加了流动性, 当一笔兑换的几个到达0.9以上的时候,区间流动性总量就要将张三的删除了,张三也收不到0.9以上兑换的手续费了。
实现细节(可跳过):寻找下一边界、计算数量与固定输入 / 固定输出
代码出处:
- TickBitmap.sol:flipTick / nextInitializedTickWithinOneWord
- SqrtPriceMath.sol:getAmount0Delta / getAmount1Delta
- SqrtPriceMath.sol:getNextSqrtPriceFromInput / Output
具体到代码中,每轮会先查tick的 bitmap ,
代码出处:
(step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
state.tick,
tickSpacing,
zeroForOne
);
。每次只搜索bitmap的一个块,所以这个函数的最后WithinOneWord 。 他的意思是 如果流动性在tick 600 . 我们从0 开始前进, 0 会到 255, 再从256 到 511 ,然后才能找到600的tick 。step.initialized 如果返回false 则没有找到真正的仓位边界。 再计算一下走到这个边界的需要多少token 。然后判断这一轮能不能到达,
代码出处:
return
roundUp
? FullMath.mulDivRoundingUp(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96)
: FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96);
简单的来说就是当前价格和目标价格 以及流动性来计算需要输入/能够输出的token ,忽略取整 这段就是。getAmount1Delta(getAmount0Delta) → L × 两个平方根价格(倒数)的差 。 在有数量后判断能不能达到分支目标 要 区分开看了,
代码出处:
if (exactIn) {
uint256 amountRemainingLessFee = FullMath.mulDiv(uint256(amountRemaining), 1e6 - feePips, 1e6);
amountIn = zeroForOne
? SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true)
: SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true);
if (amountRemainingLessFee >= amountIn) sqrtRatioNextX96 = sqrtRatioTargetX96;
else
sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput(
sqrtRatioCurrentX96,
liquidity,
amountRemainingLessFee,
zeroForOne
);
} else {
amountOut = zeroForOne
? SqrtPriceMath.getAmount1Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, false)
: SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, false);
if (uint256(-amountRemaining) >= amountOut) sqrtRatioNextX96 = sqrtRatioTargetX96;
else
sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromOutput(
sqrtRatioCurrentX96,
liquidity,
uint256(-amountRemaining),
zeroForOne
);
}
固定输入意思是 我们指定投入多少输入币,输出多少由合约计算,需要计算的是这些token 会把价格推到那里,是否可以跨越当前边界,不能够跨越tick那价格会停在那里。 举个例子, 输入 token1、输出 token0,价格上涨, 当前的价格是1 , 目标价格是1.1 (这个目标价格可能有多个来源,用户的价格限制, tick的边界,或者bitmap的边缘tick) 区间内流动性 L = 1000 ,那么走到目标价格需要的token 就是1000*(sqrt(1.1) - sqrt(1)) . 剩余净预算不足,就在边界前结束;预算足够,就先走到本轮边界。
另一个交易方向下 固定输出固定数量的token1 ,这句话可以描述为我为了得到40个token1的情况下,我要输入多少个token0 。 结束平方根价格为 1 - 40/1000 = 0.96,对应的普通价格为 0.96² = 0.9216 ,那我需要输入的就是 1000 * (1/0.96 - 1)
一个小诀窍,计算amount1 的时候都是直接√P 就好了, amount0 都需要1/ √P 倒数一下。所以在已知开始价格 和结束价格的时候,这一段对应多少币呢?
Δtoken1 = L × |√(P_(结束))-√(P_(开始))| Δtoken0 = L × |1 / √(P_(结束))- 1 / √(P_(开始))|
能不能走到边界呢?
| 模式 | 指定了什么 | 先算到边界的什么数量 |
|---|---|---|
| 固定输入,token1 → token0 | 投入多少 token1 | 用根差算所需 token1 净输入,与净预算比较 |
| 固定输出,token1 → token0 | 收到多少 token0 | 用倒差算能输出多少 token0,与剩余输出需求比较 |
| 固定输入,token0 → token1 | 投入多少 token0 | 用倒差算所需 token0 净输入,与净预算比较 |
| 固定输出,token0 → token1 | 收到多少 token1 | 用根差算能输出多少 token1,与剩余输出需求比较 |
跨越边界后,换一段流动性继续成交
就是这样的方式 。走完一轮循环后,整笔订单可能还没有完成, 合约会记录指定的剩余数量(amountSpecifiedRemaining),以及目标的累计兑换数量(amountCalculated), 然后来进行下一个循环将整个兑换结束。 设想一下刚刚的例子,张三在0.8 到 0.9 之间提供 1000 的流动性, 李四在 0.8 到 1.0 之间提供 2000 L 。 当价格位于两人的重叠区间时,有效 L 是 3000 。
这个L 在添加流动性的时候计算, 流动性在合约内大概可以有3种描述, 第一种 是 仓位的L ,张三添加一次流动性,这个仓位有多少L 。 第二种 是池子的L ,当前参与交易的L 一共有多少, 第三种 tick 的变化导致L的变化(liquidityNet),价格跨越这里,总L的变化有多少 ,更加详细的等到添加流动性部分再细说。 当价格向上跨过 0.9 的时候,合约就会把当前的 L 从 3000 调整到 2000,接下来的兑换继续用李四的流动性来计算。
实现细节(可跳过):liquidityNet 如何避免逐个遍历 LP
代码出处:
这里并不需要把所有 LP 找出来重新加一遍。张三添加流动性时,合约已经在他的下边界记了 +1000,在上边界记了 -1000;李四也一样,下边界记 +2000,上边界记 -2000。所以两个人都添加完之后,边界上保存的是这样的结果: 0.8 对应的 tick:liquidityNet = +3000 0.9 就是 -1000 1.0 就是 -2000 向上跨过 0.9,就加上 -1000;向下跨回来,就反过来加上 1000。
跨边界本身不会把价格重新设一遍,价格还在那里,下一轮只是换了一个 L。张三的仓位也没有被删除,等价格回来,他的资金会重新参与兑换 。
成交结束:保存状态、转账与回调
代码出处:
uniswap 中有很多精妙的设计。对于智能合约而言,代码的优雅性 和 数据结构算法的使用,对于问题的解决方案真的很棒。等到指定数量完成,或者价格碰到了限制,循环就结束了。池子保存新的价格、tick 和有效 L,再处理实际的代币转账,在v3 内,它会先把你应该收到的币转出去,然后再用callback让支付方将需要支付的币补进来 ,池子会检查余额 ,不够的话就会revert , 不过你已经消耗的gas 依旧要支付。
如何添加流动性
代码出处:
- NonfungiblePositionManager.sol:mint
- LiquidityManagement.sol:addLiquidity,计算 L 并调用 pool.mint
- UniswapV3Pool.sol:mint 与付款检查
那到底作为流动性的提供者,我们如何把钱存进去呢? 假设你愿意拿一部分港币和人民币放进来,提供 0.8 到 0.9 这段价格的兑换。你需要告诉合约是哪一个池子、上下边界在哪里,以及两种币最多愿意拿出多少。平时在界面里操作,通常是调用外围的 NonfungiblePositionManager.mint(), 它会帮你计算 L,再去调用核心池的 mint(),
实现细节(可跳过):tickSpacing 与边界处理成本
代码出处:
- UniswapV3Factory.sol:费率、getPool 与 createPool
- TickBitmap.sol:flipTick / nextInitializedTickWithinOneWord
在实际的操作中,仓位的边界会有 tickSpacing 的约束, 比如间隔是 60,那么 tick 设定 就要能被60 整除, 比如tick 的 某一段就是 60 120 180 ... 600 这样子。 设置更大的间隔,是在“LP 选区间的精细程度”和“交易执行成本”之间做取舍,价格经过普通刻度时,不需要改变有效 L;但经过一个已初始化的仓位边界,合约需要做我们上面讲的那些边界的处理,对于gas 的消耗会变多,假设一笔交易把 tick 从 0 推到 600 ,如果按1 的间隔,那么就要处理600次跨界,而如果是60 的间隔,只需要处理10次, 像 USDC/USDT 这样的稳定币交易对,更需要细密的仓位边界,因此常见于 tickSpacing 较小的低费率池。
相对价格波动较大的交易对,则常见于间隔更大的较高费率池 。
实现细节(可跳过):从两种币的数量计算 L
代码出处:
那么流动性数量是如何计算的呢? 前面说过 token0 和 token1 的计算
Δtoken1 = L × |√(P_(结束))-√(P_(开始))| Δtoken0 = L × |1 / √(P_(结束))- 1 / √(P_(开始))|
所以反过来,现在知道token 数量,
L0 = amount0 / |1 / √(P_(结束))- 1 / √(P_(开始))| L1 = amount1 / |√(P_(结束))-√(P_(开始))|
那本次添加的流动性就是 min(L0,L1 ) 两个里面取最小, 比如你准备了很多港币,但人民币只够支持 1000 L,那么这次就只能添加这么多,没用上的港币不需要硬塞进仓位。两种币应该放多少, 取决于当前价格和你选的区间,并没有一条必须各放一半价值的规定。如果当前价格低于整个区间,只需要 token0;高于整个区间,只需要 token1。
添加成功后,更新仓位与边界
代码出处:
添加成功后,合约会更新这个仓位的 L,以及上下两个边界的记录 如果仓位当前就在价格范围内,池子的有效总 L 也会立即增加。哪怕一个仓位横跨几千个 tick,也不需要把中间每个 tick 都写一遍,只更新两个端点就够了。
实现细节(可跳过):liquidityGross 与边界清理
代码出处:
除了 liquidityNet,每个边界还有一个 liquidityGross,记录以这里为边界的仓位一共挂着多少 L。比如一个人正好在这里退出 1000 L,另一个人进入 1000 L,净变化虽然是 0,但这个边界还被两份仓位使用,手续费的区间记账仍然需要它。只有这些仓位都撤走了,边界才可以从 bitmap 里清掉。
实现细节(可跳过):Core 如何定位一份仓位
代码出处:
那合约怎么知道其中多少是你的?core合约内使用一个 mapping,
keccak256(abi.encodePacked(owner, tickLower, tickUpper))
同一个池子里,同一个 owner 在同一个区间继续添加,L 就累加到这条记录上 。
NFT 如何表示仓位与归属
从v3 开始 流动性是用NFT 表示的, 这个的owner 是NFT 管理合约地址, 并不是每个用户的唯一,而是 每次上下边界一样的时候,对于core 合约而言都是一样的。 在NFT管理合约内,管理合约用不同 NFT,分别记录两个人的份额,
代码出处:
mapping(uint256 => Position) private _positions;
nft 是另外一种token的方式,你可以把它想象成银行卡,每个人的卡都是唯一的。 V2 的 LP 份额可以用同一种 ERC20 代币表示,大家按比例持有同一个全区间池子。到了 V3,每个人选择的区间可能不同,同样的本金也可能对应不同的 L,所以常见的管理方式变成了一份仓位一个 NFT。同一个人也可以持有很多份,分别做不同区间的生意。 这张 NFT 是可以转走的。假设你把自己的仓位 NFT 转给李四,他就可以领取这份仓位尚未领取的手续费,也可以撤出剩余本金,连转移前已经赚到但还没领取的部分也一起归他控制。
LP 的手续费从哪里来
代码出处:
手续费到底是怎么分给张三和李四的? 首先要看手续费是怎么来的 ,每次的swap 都会按池子的费率支付输入币作为手续费。比如费率是 0.3%,客人支付 1000 个输 入币,忽略取整,可以理解为 997 个参与兑换,3 个是手续费,协议分成,这 3 个里还会先划走一部分,剩下的才分给 LP,若协议分成设为 六分之一,就会划出 0.5 个归协议,剩下 2.5 个由参与这笔兑换的 LP 分配 。
手续费是按每一小段成交当时的有效 L 来分的。假设价格还在张三和李四的重叠区间,这一小段产生了 30 元分给 LP 的人民币手续费, 张三占 1000/3000,就分到 10 元;李四占 2000/3000,分到 20 元。价格越过 0.9 之后,张三不再参与后面的成交,也就不再分后面的手续费。
实现细节(可跳过):全局账、边界账与仓位快照
代码出处:
- UniswapV3Pool.sol:feeGrowthGlobal、ticks、tickBitmap、positions
- Tick.sol:Tick.Info
- Position.sol:Position.Info 与 Last 快照
这部分要如何优雅的在代码中实现呢? 首先有3个字段需要留意 全局账 :
uint256 public override feeGrowthGlobal0X128;
uint256 public override feeGrowthGlobal1X128;
feeGrowth就是手续费累计增长,Global 整个池子共用 , 0 和 1 就是分别代码token0 和 token1 。 X128就是数值 乘以 2^128 。 合约中记的是每单位 L 累计赚了多少手续费,比如 30 / 3000 = 0.01 , 这样的好处是,如果记录单个用户的话 张三:30 * 1000/3000 = 10 元 。 李四:30 * 2000/3000 = 20 元 。
但是在合约中池子只记录了单位L的手续费。张三就可以直接 1000 * 0.01 = 10 ,李四也是一样的。 类似的意思是如果未来又有一次兑换,假设第二段的单位L的手续费是0.2 , 那么这个累计就是 0.3 。 . 0 和 1 两个字段的意思是因为一笔流动性,会有两个币种的手续费需要记录 。
仓位的边界信息,
mapping(int24 => Tick.Info) public override ticks;
这个的key是 tick .所以是多人公用的,没有分用户。比如张三选 [-120, 120),李四选 [-120, 240),两人共用 ticks[-120] 这条边界记录。 Tick.Info 中相关的字段 liquidityGross的意思就是 该tick的L总和, liquidityNet 这个在前面swap ,和流动性添加的时候说了,当价格跨越这个tick的时候,有效的L 要增加或者减少多少。
feeGrowthOutside0/1X128 这个用来区分边界两侧手续费增长的累计账本 。 第三个需要留意的 就是 到用户了, 用户的流动性仓位边界,
mapping(bytes32 => Position.Info) public override positions;
。 其中 key 是用owner 和 tickLower tickUpper 算hash 来定位的。 在用户的仓位记录内 会记下 上次已经结算到那个读数了, feeGrowthInside0LastX128 / feeGrowthInside1LastX128 .Last 表示上次结算时,该区间累计表的读数。 ,如果用口语描述整个的运行逻辑 简单而言就是 张三上次结算时,人民币区间累计值:0.05 现在人民币区间累计值:0.08,那它新增的人民币手续费:0.03 * 1000 = 30 。
实现细节(可跳过):feeGrowthInside 计算与越界后的手续费
代码出处:
feeGrowthInside 是用这些记录现算出来的, 在价格在当前区间内的话,
区间内累计值 = 全局累计值 - 下边界以下的累计值 - 上边界以上的累计值
假设全局累计值 G
| 要算什么 | 当前 tick 的位置 | 计算方法 |
|---|---|---|
| 下边界以下的增长 | 当前 tick ≥ 下边界 | lower.outside |
| 下边界以下的增长 | 当前 tick < 下边界 | G - lower.outside |
| 上边界以上的增长 | 当前 tick < 上边界 | upper.outside |
| 上边界以上的增长 | 当前 tick ≥ 上边界 | G - upper.outside |
它是怎么判断 用户不再区间内提供流动性,从而不给他奖励的呢? 假设全局的累计值是 0.01 下/上边界 outside = 0 。 那么Inside = 0.01 - 0 - 0 = 0.01 。 当价格上涨跨出仓位的时候, 新的 upper.outside = G - 旧的 upper.outside = 0.01 - 0 。出区间后,其他的lp 赚了手续费 全局 的累计手续费变成0.03, 上边界 outside 仍为 0.01 因为当前价格已经在上边界之上,计算“上边界以上的增长”要用, 上方增长 = G - upper.outside = = 0.03 - 0.01 。
所以这个仓位的区间内累计值依旧是 Inside = 全局 - 下方 - 上方 = 0.03 - 0 - 0.02 = 0.01 全局增长了,但这个仓位的 Inside 没增长。 这样,区间外产生的手续费就不会分给它
结算与复投
代码出处:
总结一下整个流程就是 交易产生手续费 (feeGrowthGlobal0/1X128,全池每段的单位 L 手续费增长) 结合仓位上下边界,算出 feeGrowthInside0/1X128,减去仓位的 Last 快照 * L 除以 2^128 就是新增收益,记入 tokensOwed0/1 。 这样就算中间发生了很多次兑换,价格进进出出,结算一个仓位时也只需要读它的快照、两个边界和全局状态。
用户增加或者减少 L 时, 会先用原来的 L 结算已经发生的手续费,再记录新的 L,后来加进来的钱就不会分走前面赚到的部分。 V3 的手续费也不会自动变成新的L。它单独累计,结算后记入待领取金额,你想复投,需要领取后重新添加;看到某些产品会自动复投, 是外面又包了一层策略。
移除流动性与领取手续费
代码出处:
- NonfungiblePositionManager.sol:decreaseLiquidity
- NonfungiblePositionManager.sol:collect,先刷新再领取
- NonfungiblePositionManager.sol:burn(tokenId)
- UniswapV3Pool.sol:collect,转出已记账金额
等你不想继续做这段生意,就可以移除流动性了
- decreaseLiquidity:减少仓位的 L,算出可以取回的 token0 和 token1
- collect:把已记账的待领取代币转到指定地址 。
对于从池子里拿钱有两个地方,一个是赎回流动性,一个是提取收益。 比如张三从 1000 L 撤出 200 L,个人记录剩下 800 L,下边界的净增加减少 200,上边界原来要减掉的数量也少 200。如果当前价格还在他的区间内,池子的有效 L 同时减去 200。撤出的本金先记成待领取金额,如果只是领取手续费,就不需要减少 L。NFT 管理合约的 collect 会先刷新收益,再转出可领的部分;
实现细节(可跳过):decreaseLiquidity → Core burn 的调用过程
我们详细的来看看这部分在合约内是如何流转的 。 可以把它理解为3个动作, 减少营业资金、领取已结算的钱、销毁空仓位凭证 。 他们对应的函数分别是 decreaseLiquidity , collect , burn(tokenId) 。 首先 decreaseLiquidity:告诉合约“我要减少多少 L” ,接口的定义在 INonfungiblePositionManager.sol
代码出处:
- INonfungiblePositionManager.sol:DecreaseLiquidityParams 与接口
- NonfungiblePositionManager.sol:decreaseLiquidity
struct DecreaseLiquidityParams {
uint256 tokenId; // 操作哪一份 NFT 仓位
uint128 liquidity; // 本次减少多少 L,不是减少后剩多少
uint256 amount0Min; // 本次减少 L,至少应记账多少 token0 本金,交易发出后,价格可能变化,对应的两种币数量也会变化;如果算出的本金低于 这些下限,就回滚。
uint256 amount1Min; // 本次减少 L,至少应记账多少 token1 本金
uint256 deadline; // 交易执行期限,Unix 时间戳,单位为秒
}
function decreaseLiquidity(
DecreaseLiquidityParams calldata params
) external payable returns (
uint256 amount0, // 本次减少 L 对应的 token0 本金
uint256 amount1 // 本次减少 L 对应的 token1 本金
);
张三这次的请求就是
tokenId = 101 liquidity = 200
这部分是和管理合约交互, 所以大概的流程就是
张三操作 NFT #101
↓
管理合约读取 #101 的池子、上下 tick
↓
管理合约调用 Core 的 pool.burn(lower, upper, 200)
Core合约 看到的调用者是管理合约,用户在管理合约内区分, Core 的 burn:把减少 L 表示成负的变化量
代码出处:
function burn(
int24 tickLower, // 仓位下边界
int24 tickUpper, // 仓位上边界
uint128 amount // 本次减少的 L;传 0 可用于刷新已有仓位的手续费
) external returns (
uint256 amount0,
uint256 amount1
);
代码中关键是:
代码出处:
liquidityDelta: -int256(amount).toInt128()
价格变化后,LP 仓位会变成什么
减少200 L 就是按照当前的价格 和 仓位边界,计算这部分的L 对应的资产,取回来的两种币,数量通常已经和存入时不一样了。我们一直说仓位有 1000 L,这个数在你没有增减仓位时可以保持不变,但它对应的港币和人民币会随着兑换不断变化 如果把港币当作 token0,人民币当作 token1,港币报价上涨,就是客人不断买走港币,你的仓位会越来越多人民币,越来越少港币。走到上边界,参与做市的本金全部变成人民币;反过来跌到下边界,本金全部变成港币 所以组 LP 的时候,你已经同意了一件事:在你选定的范围内,币涨了就逐步卖出,跌了就逐步买入。
价格涨出上边界后,你拿着卖币得到的另一种资产,不会再跟着原来的币一起上涨。跌出下边界后,你拿着买回来的币,它继续跌,你仍然承担这部分价格损失。普通的、 没有借贷杠杆的 LP 仓位不会因为越界被清算,但越界也不会替你止损 。
ETH / USDC 的持仓对比
这部分我觉得需要举一个例子来讲一下, 假设 我们有 5000 USDC 和 2.5 ETH. 当前 ETH 的价格是 2000 USD .
| ETH 价格 | LP 的本金组成,约数 | LP 本金价值 | 原样持有 2.5 ETH+5000 USDC | 10000 美元全部买 ETH,持有 5 ETH |
|---|---|---|---|---|
| 2000 美元 | 2.5 ETH+5000 USDC | 10,000 美元 | 10,000 美元 | 10,000 美元 |
| 涨到 4000 美元 | 12,071.07 USDC | 12,071.07 美元 | 15,000 美元 | 20,000 美元 |
| 继续涨到 6000 美元 | 12,071.07 USDC | 12,071.07 美元 | 20,000 美元 | 30,000 美元 |
| 跌到 1000 美元 | 6.0355 ETH | 6,035.53 美元 | 7,500 美元 | 5,000 美元 |
| 继续跌到 500 美元 | 6.0355 ETH | 3,017.77 美元 | 6,250 美元 | 2,500 美元 |
无常损失与调区间的成本
我们常会听到 “无常损失” 。 在这里呢看图中,把一份资产放进 LP 后,因为自动兑换改变了持仓,导致它的价值低于“原样拿着这份资产不动”的价值 。 上涨时候会不断卖出ETH , 下跌的时候会不断买入 ETH . “无常”指的是:这个差额可能随着相对价格回归而缩小,甚至消失。 如果价格继续远离初始价格,差距还可能扩大。尤其区间 LP 越界后会变成单一资产,风险也不会自动停止。所以不要因为名字里有“无常”两个字,就把它当成暂时不用管的损失。
而且调区间也有成本。假设跌出范围后,你的人民币已经换成了港币,此时要把区间重新放到当前价格两侧,通常得先卖掉一部分港币, 换回人民币,再重新添加。每次追着价格调整,都可能发生兑换、手续费和 gas 支出。窄区间确实能让同样的资金在附近提供更多深度, 但也会更快把仓位从一种币换成另一种币。
套利如何让池内价格跟随外部市场
在最开始找换店的例子中,我们一直让找换店只看自己的库存,那它怎么跟外面的价格保持一致? ?靠的是有人来做套利。假设外面一港币能换 0.95 元人民币, 你店里还按接近 0.9 的价格卖,就会有人从你这里买港币,拿到外面卖。买的人多了,你的港币库存减少,报价被推高,直到价差不足以覆盖手续费、gas 和其他执行成本。
链上应该观察什么
代码出处:
- UniswapV3Pool.sol:slot0、liquidity、ticks 与 tickBitmap
- NonfungiblePositionManager.sol:positions(tokenId)
- IUniswapV3PoolEvents.sol:池子事件
所以总结一下的话,看一个池子的流动性可以查看 slot0() 里面有当前的 sqrtPriceX96 和 tick。接着看 liquidity(),它表示当前有效的 L。再读附近的 tick 和 bitmap,就能知道价格向两边走时,哪些地方会有流动性进入,哪些地方会退出。TVL 是池子里资产的总价值,很多资金可能放在离当前价格很远的区间,对眼前这笔兑换帮不上忙 .要判断自己这一单会把价格推多远,还是要沿着实际方向,用各段有效 L 来计算。
不同币对、不同精度下的 L,也不能直接拿数字大小比较。 如果关心某个人的仓位,就从 NFT 的 tokenId 去查持有人、区间、L 和收益记录。想长期跟踪,可以在链下索引池子的 Mint、Burn、 Swap、Collect 事件,再结合 NFT 的 Transfer 事件更新归属。 tokensOwed 是上次更新时已经记账的金额,单读这个字段可能漏掉后来的成交。历史 APR 也要看统计窗口、仓位在区间内的时间、是否包含额外代币奖励,以及复投和调仓成本
打狗时的费用、价格冲击与滑点
代码出处:
打狗时界面上那些“手续费” 大概有那些呢?
- 池子的兑换费, swap 按费率收取的费用。V3 如果开启协议分成,会从中划出一部分,其余归当时有效的 LP
- 网络的 gas 是执行和打包交易的成本
- 还有一种是代币合约自己的转账税 ,有的ERC20 会重写转账函数,收取转账费。
- 价格冲击和滑点容忍度也要分清。价格冲击是你的交易本身沿着曲线推动价格,前面用 10000 元买走一万港币的例子就是这样。滑点容忍度则是你允许实际成交相对报价偏离多少,通常通过最少收到多少、最多支付多少来约束。设成 20%,并不代表协议固定收走 20%,但确实把你愿意接受的成交范围放宽了。
夹子交易与实际到账
在能观察和安排交易顺序的环境里,别人还可能抢在你前面买入,把你的成交价格推差,再在你后面卖出,这就是常说的夹子交易。它给你造成的损失也不会作为一笔普通 LP 手续费显示出来。大滑点给这类交易留下的空间会更多,算一单到底花了多少,最终还是要对比自己的实际投入和实际到账。
闪电贷:同一笔交易内借出与归还
代码出处:
还有很好玩的一个功能叫作 闪电贷。 前面提到先转币、后检查付款,。V3 的池子有单独的 flash(),可以先把 token0 或 token1 借给一个合约,再调用它的 uniswapV3FlashCallback。这个合约可以拿钱去别的池子做兑换或者偿还另一笔债务,但必须在回调结束前补回本金和规定的手续费。归还不足,这笔交易就会回滚。 你可以如何理解? 就把整个区块链的某比交易当作 整个链只服务你一个用户,你可以在你交易的时候借池子内的所有金额,只要确保你可以在你交易结束的这个时间,还掉借款,并且带一部分手续费,就可以成功借款, 比如两个市场之间有足够的价差,你可以在一笔交易里借出资金,在便宜的地方买、贵的地方卖,再归还借款。
扣掉借款手续费和 gas, 剩下的差额才是收益。套利合约还可以检查余额并设置最低利润,不够还款或者检查不通过,就让操作失败,已经用掉的 gas 仍然要付。 借款只能在这一次链上执行里周转,不能拿去等明天涨了再还。当然不需要你有任何的资产证明,也没有任何办法验证信用。 只要能还钱就行。
V4:留到下一篇
那到了 V4,这套东西改了多少? 下一篇再讲 v4 吧, 篇幅太长了。