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 结束。说清楚

  1. 价格是怎么来的,最简单的amm 是什么样子的。
  2. 一个池子如何诞生的
  3. 如何添加流动性,什么是集中流动性,tick到底是什么。
  4. 一笔swap是如何穿过合约的
  5. lp的手续费是那里来的
  6. 移除流动性和领取手续费
  7. 价格变化后,LP 仓位会变成什么
  8. 链上应该观察什么
  9. 打狗的手续费都有什么?
  10. 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 存储

代码出处:

在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以上兑换的手续费了。

实现细节(可跳过):寻找下一边界、计算数量与固定输入 / 固定输出

代码出处:

具体到代码中,每轮会先查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 依旧要支付。

如何添加流动性

代码出处:

那到底作为流动性的提供者,我们如何把钱存进去呢? 假设你愿意拿一部分港币和人民币放进来,提供 0.8 到 0.9 这段价格的兑换。你需要告诉合约是哪一个池子、上下边界在哪里,以及两种币最多愿意拿出多少。平时在界面里操作,通常是调用外围的 NonfungiblePositionManager.mint(), 它会帮你计算 L,再去调用核心池的 mint(),

实现细节(可跳过):tickSpacing 与边界处理成本

代码出处:

在实际的操作中,仓位的边界会有 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 之后,张三不再参与后面的成交,也就不再分后面的手续费。

实现细节(可跳过):全局账、边界账与仓位快照

代码出处:

这部分要如何优雅的在代码中实现呢? 首先有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。它单独累计,结算后记入待领取金额,你想复投,需要领取后重新添加;看到某些产品会自动复投, 是外面又包了一层策略。

移除流动性与领取手续费

代码出处:

等你不想继续做这段生意,就可以移除流动性了

  1. decreaseLiquidity:减少仓位的 L,算出可以取回的 token0 和 token1
  2. 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

代码出处:

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 和其他执行成本。

链上应该观察什么

代码出处:

所以总结一下的话,看一个池子的流动性可以查看 slot0() 里面有当前的 sqrtPriceX96 和 tick。接着看 liquidity(),它表示当前有效的 L。再读附近的 tick 和 bitmap,就能知道价格向两边走时,哪些地方会有流动性进入,哪些地方会退出。TVL 是池子里资产的总价值,很多资金可能放在离当前价格很远的区间,对眼前这笔兑换帮不上忙 .要判断自己这一单会把价格推多远,还是要沿着实际方向,用各段有效 L 来计算。

不同币对、不同精度下的 L,也不能直接拿数字大小比较。 如果关心某个人的仓位,就从 NFT 的 tokenId 去查持有人、区间、L 和收益记录。想长期跟踪,可以在链下索引池子的 Mint、Burn、 Swap、Collect 事件,再结合 NFT 的 Transfer 事件更新归属。 tokensOwed 是上次更新时已经记账的金额,单读这个字段可能漏掉后来的成交。历史 APR 也要看统计窗口、仓位在区间内的时间、是否包含额外代币奖励,以及复投和调仓成本

打狗时的费用、价格冲击与滑点

代码出处:

打狗时界面上那些“手续费” 大概有那些呢?

  1. 池子的兑换费, swap 按费率收取的费用。V3 如果开启协议分成,会从中划出一部分,其余归当时有效的 LP
  2. 网络的 gas 是执行和打包交易的成本
  3. 还有一种是代币合约自己的转账税 ,有的ERC20 会重写转账函数,收取转账费。
  4. 价格冲击和滑点容忍度也要分清。价格冲击是你的交易本身沿着曲线推动价格,前面用 10000 元买走一万港币的例子就是这样。滑点容忍度则是你允许实际成交相对报价偏离多少,通常通过最少收到多少、最多支付多少来约束。设成 20%,并不代表协议固定收走 20%,但确实把你愿意接受的成交范围放宽了。

夹子交易与实际到账

在能观察和安排交易顺序的环境里,别人还可能抢在你前面买入,把你的成交价格推差,再在你后面卖出,这就是常说的夹子交易。它给你造成的损失也不会作为一笔普通 LP 手续费显示出来。大滑点给这类交易留下的空间会更多,算一单到底花了多少,最终还是要对比自己的实际投入和实际到账。

闪电贷:同一笔交易内借出与归还

代码出处:

还有很好玩的一个功能叫作 闪电贷。 前面提到先转币、后检查付款,。V3 的池子有单独的 flash(),可以先把 token0 或 token1 借给一个合约,再调用它的 uniswapV3FlashCallback。这个合约可以拿钱去别的池子做兑换或者偿还另一笔债务,但必须在回调结束前补回本金和规定的手续费。归还不足,这笔交易就会回滚。 你可以如何理解? 就把整个区块链的某比交易当作 整个链只服务你一个用户,你可以在你交易的时候借池子内的所有金额,只要确保你可以在你交易结束的这个时间,还掉借款,并且带一部分手续费,就可以成功借款, 比如两个市场之间有足够的价差,你可以在一笔交易里借出资金,在便宜的地方买、贵的地方卖,再归还借款。

扣掉借款手续费和 gas, 剩下的差额才是收益。套利合约还可以检查余额并设置最低利润,不够还款或者检查不通过,就让操作失败,已经用掉的 gas 仍然要付。 借款只能在这一次链上执行里周转,不能拿去等明天涨了再还。当然不需要你有任何的资产证明,也没有任何办法验证信用。 只要能还钱就行。

V4:留到下一篇

那到了 V4,这套东西改了多少? 下一篇再讲 v4 吧, 篇幅太长了。

源码版本(排版补充)