朋友们大家好!今天我们的目标是攻克uniswapV3最核心的代码pool.sol。
前29行都是在导入库文件,用到的时候我们逐一去看。
第30行开始进入合约,合约名称叫UniswapV3Pool,继承自IUniswapV3Pool, NoDelegateCall。IUniswapV3Pool是一个接口,具体的实现会在继承后完成;NoDelegateCall是一个拒绝代理调用的能力,之前一起分析过,DeFi 源码逐行读 #7 | 防止委托调用:UniswapV3 NoDelegateCall.sol可以点击链接查看。

第31行用到了LowGasSafeMath,把LowGasSafeMath的能力加在了uint256。在uniswapV2中我们也见过safemath,主要的目的是为了防止运算结果溢出。以add函数为例对比两个safemath就能看出来区别,主要是去掉了require的错误字符。错误字符串在EVM中会被编码为ABI格式的revert数据,需要在内存中拼接并返回,会产生额外的内存扩展和日志操作gas成本,在LowGasSafeMath中直接使用require(condition),无错误信息,回滚时只消耗少量gas,不附带任何数据。

32行用到了SafeCast,这是一个做格式转换的库,把大范围的整数转换成小范围的整数,比如从uint256转换为uint160。叫SafeCast是因为额外做了安全检查,如果数据溢出了会回滚,确保只有能转化的数据才会成功转换。

35行用到了Tick,Tick从字面理解是钟表发出的滴答声,就像钟表每次走一格一样,tick在这里也是指价格刻度间距的最小步幅。Tick.sol是UniswapV3中管理价格刻度状态的核心库。
Tick.sol用到了TickMath.sol,关于TickMath的详细介绍可以看这里。TickMath主要完成了对于给定的tick计算出√P,以及基于给定的√P计算出符合条件的最大tick。Tick.sol的详细介绍可以看这里。
Tick.sol是用来管理每个已初始化Tick的所有数据,包括流动性总额、净流动性、手续费增长、预言机数据。
36行用到了TickBitmap,在这里我们详细解释了什么是位图,如何快速通过位图找到下一个有流动性的tick。
37行用到了Position,39行用到了Oracle,详细解释可以看这里。
还没进入正文准备工作已经这么多了……

42行开始定义了一系列变量/常量,工厂地址factory、两个token的地址、手续费fee、tickSpacing和每个tick上的最大流动性maxLiquidityPerTick。
56至72行定义了一个结构体叫Slot,如果你之前读过Oracle.sol,就会发现这个结构体里存了很多和Observation相关的字段,不过Slot是池子的当前状态,不存储历史是它和Observation观测点的最大区别。

一系列的变量定义之后,是两个修饰器lock和onlyFactoryOwner,这两个之前也介绍过,主要是防重入,和避免其他地址调用。
constructor的构建函数在factory中做过介绍,相比V2更加灵活好用,可以查看Factory的介绍。
函数checkTicks
126行终于进入第一个函数,这是一个单纯的计算函数,主要是为了校验tick的上下限是否在范围里,名副其实的“检查tick”。

函数_blockTimestamp
这个函数也非常简单,就是为了把时间戳变成uint32格式的。

函数balance0和balance1
这是两个获取池中token0和token1余额的函数。

获取代币余额的方式有很多,比如可以直接调用balanceOf,但直接调用接口Solidity编译器会在调用前后自动插入一些检查,比如注释中提到的extcodesize检查,这是在确认目标地址确实是一个合约,这一步在require中通过检查success也补上了没有漏掉,所以能省一点是一点。
函数snapshotCumulativesInside
snapshot是快照,Cumulatives是累计值主要指tick和liquidity的时间加权累计,Inside是指价格区间内部。合起来这个函数的意思是指定价格区间内部的预言机累计值的快照。

因此这个函数的入参是价格区间:tickLower和tickUpper。出参是预言机的两个累计值以及该区间内的秒数累计。
169行先调用checkTicks函数,检查tick是否符合tick的区间标准。

171至176行定义了三组变量,分别是上下界价格上预言机存储的tick累计、liquidity累计和时间累计。
178至198行通过访问对应价格的ticks信息,读取对应的值并赋值给171-176行定义的局部变量。
200行定义了个变量叫_slot0,把池子当前的状态slot0赋值给它。

接下来要计算价格区间内的累计tick和liquidity,基于当前价格与函数输入的价格上下界分为三种情况。202到233行就分情况处理这三种情况下区间内累计值的计算。核心的逻辑是弄明白什么是outside,这个概念我们理解tick流动性的文章里过解释,可以点击这里跳转。

函数observe
这个函数的内容非常简单,从observe的名字可以推断与Oracle库联系紧密,在Oracle是一个纯数学&逻辑库,而Pool合约通过封装函数把分散在Slot0、liquidity等位置状态和变量收集在一起,统一交由库函数处理,而外部调用者只需要访问Pool中的这个函数即可。

函数increaseObservationCardinalityNext
这是Pool对外提供的预言机数组扩容接口,从内容来看非常简单,主要是调用了Oracle中的grow函数,完成了观测点数组的扩容,同时更新slot中观测点数组的长度,并对外发布事件。

函数initialize
这是Pool的初始化函数,完成之后就可以接受mint、swap、burn等核心操作了。初始化主要完成三件事:基于外部传入价格计算tick、初始化预言机、初始化slot0。

272行首先检查是不是首次调用,这个初始化函数只能被调用一次,因为require要求√P等于0,一旦不为0意味着已经被初始化过了,就会返回AI,AI是Already Initialized的缩写。
274行调用TickMath库,基于给定的√P计算出满足条件的最大tick,我们之前分析过这个函数,价格P和tick的关系是P=1.0001^tick,所以调用的getTickAtSqrtRatio函数是一个实质上完成开方运算的函数。
278行把目前已知信息写入slot0中,代表池子的最新状态。注意这里unlocked的状态是true,这也是初始化函数不能加locked修饰器的原因,一开始要设定为“未锁定”,后续才能正常运转。
288行对广播事件,记录池子初始价格和对应的tick。
结构体ModifyPositionParams
这个结构体名字叫ModifyPositionParams意思是修改仓位的参数们,所以它的主要是传参用的,把修改仓位需要用到的信息owner、价格区间、流动性变化统一打包,这样在mint和burn中就不用反复写同一套参数列表。接下来的几个函数也都是关于仓位修改的。

函数_modifyPosition
这是个前边加下划线的修改仓位的函数是一个private私有函数。入参是上边提到的结构体ModifyPositionParams,这样可以一次性传入修改仓位的关键信息。出参是三个字段:仓位信息position以及两种token需要转入或者转出的数量。

315至317行在做一些基础的检查以及把slot0的信息读入内存,因为后续需要频繁用到tick和√P,内存变量比较省gas。

319行调用了_updatePosition函数,这个函数马上下一个会分析到。主要目的是更新仓位信息,主要内容包括liquidity和手续费的积累情况。

327行进入逻辑处理部分。首先确认流动性的变化量是否为0,如果是0意味着调用者只想结算手续费,不改变流动性,可以保持amount0和amount1为0。
然后是熟悉的tick与价格上下限的三分判断逻辑。
分支一:当前价格在价格下界以下

tick完全不在仓位限定的价格范围内,此时全部的仓位都是token0。所以需要增加或者移除流动性只需要变更 token0的数量。在SqrtPriceMath里我们讨论过 token0的数量 X=L*(1/√P-1/√Pb),也就是说注入流动性的多少正比于提供流动性的价格区间大小,注入流动性的多少也正比于提供的代币数量多少。调用的getAmount0Delta函数就是在完成 token0数量 ΔX的计算。
分支二:当前价格介于上下界之间
这是最复杂的情况,流动性的变化需要同步变更两种代币的数量,并且由于价格在区间内,修改流动性同时会影响整个池子的流动性。

首先更新更新预言机的观测点,调用了Oracle.sol中的write函数,详情可以参见这里。

由于当前价格tick落在LP提供的价格区间之间,所以需要按比例注入两种代币。可以理解用当前价格tick把价格lower到upper一分为二,token0提供tick到upper区间的数量,token1提供lower到tick区间的数量。
最后361行更新全局流动性,把流动性的变化量ΔL加在全局流动性上。
分支三:当前价格在价格上界以上
和分支一类似,此时用户只需要提供token1就能提供流动性,同样调用了SqrtPriceMath中的函数,计算token1的数量 Y=L*(√P-√Pb),getAmount1Delta的函数解析详见这里。

函数_updatePosition
从名字可以看出来这是一个更新仓位的函数,private的属性表明这是一个私有函数,主要用于合约内部调用。

入参是5个核心字段:仓位所有者地址owner、提供流动性的价格区间tickLowe和tickUpper、需要变更的流动性增量和当前的价格tick。出参返回的仓位信息position。

首先获取当前仓位信息,然后把全局手续费加载到内存里省一些gas。

392行定义了两个flipped变量,用于后续判断是否需要翻转tick。
394行,如果流动性有变化,那么更新流动性观察点,获取tick累计和流动性累计。
基于最新的流动性累计、tick和手续费费等一系列信息,调用ticks.update函数来判断给定价格的上下界是否需要翻转。ticks.update函数解析可以参考这里。

如果流动性穿过上界或者下界了,就翻转。

接着更新手续费、更新仓位。

445到451行在判断如果对应tick上的流动性从有变为无,会调用clear函数,把对应的info信息删除,这样会释放存储空间。
总结一下,更新仓位做的动作包括:更新预言机观察点、更新tick信息、计算手续费、清理无用tick信息。
函数mint
这是一个铸造函数,目的是基于给定的价格区间和需要的流动性数量,计算需要注入的代币数量。

入参是5个字段,分别是用户接收LP代币的地址recipient,提供流动性的价格区间tickLower和tickUpper,添加流动性的数量以及回调数据data。
出参返回所需流动性对应的两种代币的数量。

464行的判定要求流动性的增量为正,因为铸造0流动性是没有意义的。
计算两种代币在对应流动性增量和价格区间内的数量,调用的是刚刚提到的_modifyPosition函数。

把对应的两种代币数量写入amount0和amount1两个字段中。只有当amount0或者amount1大于0才调用balance()函数,这是因为如果amount为0意味着用户不需要注入对应的代币,就可以节省一次调用。

msg.sender是调用mint的地址,回调函数uniswapV3MintCallback会收到所需的代币数量amount0、amount1和data。回调是为了将对应的token数量转入Pool合约中。483和484行在校验是否转入成功了:回调后池子是否至少多了amount个token。
最后对外广播事件mint,谁在什么价格区间上增加了多少流动性,对应投入的token数量是多少。
函数collect
collect是采摘收集的意思,这个函数就是在“采摘”已结算的手续费/赎回代币。从指定仓位中,将已经累加到tokensOwed中的代币转账给收取地址。

入参是接受转账的地址、仓位的价格区间以及用户希望提取的两种token的数量。
出参数实际提取的两种token的数量。

函数体从498行开始,首先获取当前的仓位信息。
用户希望提取的数量可能大于仓位的可提取余额,所以会返回更小的那个值,保证不会超额提取。

确认提取金额之后,如果金额>0,首先在可提取额度中减掉对应数目,然后调用转账函数把对应数量从池子中转账给用户的接收地址。
最后对外广播事件:recipient地址的某仓位提取了多少数量的token0和token1。
函数burn
和mint函数对应,这是一个移除流动性的函数。

入参三个字段分别是提供流动性仓位的上下界,以及要移除流动性的数量amount。
出参是移除流动性后,用户应得的两种token的数量,不过对应的token不会直接转账给用户,而需要调用collect来获取。

调用 _modifyPosition函数,基于流动性的变化量以及仓位价格上下限,计算出最新仓位信息以及需要转出的token数量。

代币数量调整和mint中的处理逻辑类似,差异是不会直接向用户转账,而是把需要返还的代币数量加到tokensOwed上,配合collect函数使用。好处是可以分多次移除流动性,然后一次性通过collect提取所有嗲比,节省gas。
最后对外广播Burn事件,仓位价格上下界、移除流动性的数量,以及对应代币的数量。
从545行到593行,是三个结构体,分别叫SwapCache、SwapState和StepComputations。
这三个结构体都是为swap函数服务的。SwapCache是存储swap开始前的快照数据,在整个swap过程中保持不变。SwapState记录swap过程中的状态变化,每次循环都会更新,最终写回链上。StepComputations是用于临时计算的数据,每次循环后被填充。
我们直接来看swap函数。
函数swap
swap函数是整个pool.sol中最核心最重要的函数。在前边铺垫了那么多的运算和逻辑之后,这个函数依然接近200行。

入参5个字段,分别是接收代币的地址recipient;zeroForOne是代币交换方向,true代表用token0换token1,false代表用token1换token0;amountSpecified用户要换的数量,正数是输入负数是输出;sqrtPriceLimitX96是价格限制,防止成交价过低或者过高;data是回调数据。
出参是两个amount,表示最终实际发生的token0和token1的转移量,正数表示池子代币数量增加用户转入,负数表示池子代币数量减少向用户支付。

一系列条件判定,首先用户要换的数量amountSpecified不能为0,不然swap啥呢?返回的错误信息“AS”是代指AmountSpecitied的两个首字母。
把最新的池子信息slot0信息读到内存里,在607行做了重入检查,要求是未锁定状态,否则回滚。
608行在判断给的价格限制sqrtPriceLimitX96是否合理。在uniswap中当我们说价格时,永远是指token1相对于token0的价格P=token1/token0。所以当zeroForOne为true,也就是用户将token0换为token1,这时池中token0数量增长token1数量减少,对应P价格下降。所以限价sqrtPriceLimitX96要比当前价格sqrtPriceX96低,但不能低过全局最低价。类似的逻辑当用户希望从token1换为token0时,池子里接收的token1变多token0变少,所以P上涨,因此限价需要大于当前价格,但要小于全局最高价。如果输入的sqrtPriceLimitX96和zeroForOne不匹配,会返回'SPL',意思是sqrtPriceLimit有问题。
当检查通过615行将slot的设置为锁定状态,锁定池子,在整个swap执行期间防止重入。

定义了一个SwapCache类型的cache,SwapCache是上述提到的用于存储swap开始前的快照数据,内容包括当前流动性;时间戳;协议费slot0Start的feeProtocol中低4位存储的是token0的协议费,高4位存储的是token1的协议费;预言机中tick和流动性的累计值初始设置为0;computedLatestObservation用来标记是否已经计算上述两个预言机的累计值,确保只查询一次。

amountSpecified是用户数据要注入或者换出的代币数量,为正意味着用户向池中注入指定数量的代币,为负意味着用户需要换出指定数量的代币。

定义了一个SwapState类型的state,SwapState是交换的状态记录。amountSpecifiedRemaining是还需处理的金额,初始写入amountSpecified,会在后续循环中不断减少直至为0;amountCalculated已经完成计算的金额,和Remaining金额相对;sqrtPriceX96当前价格,也是开始swap时的价格;tick当前价格对应的tick;feeGrowthGlobalX128代币的全局手续费累计;protocolFee协议费累计,初始为0;liquidity当前活跃的流动性,初始为cache中的初始流动性。
从641行起进入主循环,直到完成用户用户期望兑换的金额或者碰到价格限制。

642行定义了一个叫step的StepComputations类型的结构体,主要目的是用于过程中的计算,并把state中的价格赋值给step。
调用TickBitmap找到下一个有流动性的tick,函数tickBitmap.nextInitializedTickWithinOneWord的详细分析可以参见这里,zeroForOne的true或者false对应就是向更低价寻找还是向更高价格方向寻找,函数返回值是下个一有流动性的tick以及是否被初始化。

653至657行是为了确保下一个有流动性的tick在全局可接受的最大或者最小tick区间内。
660行调用getSqrtRatioAtTick函数,基于tick计算出价格√P。

663行调用了SwapMath中的computeSwapStep函数,目的是计算出在限价情况下最多可以换入/换出的金额。

673行开始计算累计的输入输出量。如果是exactInput为true,也就是固定输入量,剩余需要兑换的金额用amountSpecifiedRemaining减去本次计算出的amount和手续费;已完成计算的量amountCalculated累加上本次计算的amountOut;如果exactInput为false,也就是固定输出量,在remaining的基础上累加amountOut,已经完成计算的量是amountIn和手续费的累加和。

如果协议费开启,协议费的增量是本次计算的手续费除以协议费比例,协议费从整体的手续费中支出,剩余的部分归流动性提供商LP所有。protocolFee是对协议费用的累加。

689行更新归属LP的手续费,把本次新增的手续费转化为“每单位流动性对应的手续费”,也就是用手续费/流动性,再累加回累计手续费上。额外做了liquidity>0的判断,是因为不希望出现流动性分母为0的情况。

调用过SwapMath.computeSwapStep之后,state.sqrtPriceX96不再是初始的当前价格,而是兑换后价格被推到的位置,step.sqrtPriceNextX96是下一个有流动性tick对应的价格。
因此693行这两个价格的比较是在判断兑换后的价格碰到下一个tick了吗?
如果碰到下一个tick并且这个tick上有流动性,那说明需要穿过tick了。进入698行判断当cache.computedLatestObservation为false时,也就是说这是我们第一次穿过tick,当穿过一次之后这个字段会被赋值为1,下次就不查了。调用预言机中的observeSingle函数查询当前最新的tick和liquidity累计值。

709行计算liquidity净值,这里调用了tick的cross函数,当价格“穿过”tick时被调用的。主要完成两个动作,一是翻转tick的所有积累值,让它们反映新的另一侧,二是返回liquidityNet便于后续更新池子中中流动性。

如果zeroForOne为true,也就是价格下跌,而liquidityNet本身的定义是当价格从左向右穿过时的变化量,所以当方向是zeroForOne的兑换时,liquidityNet取反。然后把流动性的变量累加到总流动性上。
价格落在tick中,是指价格落在[tick,tick+1)区间内,所以当价格从右向左穿过tick,需要在nexttick上减一,从左向右穿过时不需要做调整。

如果本次swap执行之后的价格没有碰到下一个tick,那么完全不用管流动性和tick翻转的事情,直接基于执行后的价格算出对应的tick,赋值给state.tick。
这是一个循环,如果还有钱没换完,或者还没碰到用户的限价,就一直循环执行下去,反复判断钱换完了吗?是否需要穿过tick?
在主循环完成后,将数据写回存储。

733行首先判断在swap之后tick变化了吗?如果和最开始价格所在的tick一致,那么预言机完全不需要更新,跳转到751行只要把最近价格告知slot0就行。
如果跨越tick了,首先写入预言机新的观测点,然后更新slot0中的关于价格、观测点的一系列信息。

755行,如果流动性发生了变化也需要更新。判断当前流动性和初始流动性是否一致,如果有变化将当前存在state中的流动性赋值给liquidity。
更新输入代币对应的手续费,如果有协议费,一并累加。

计算最终的token0和token1,主要是判断是token0和token1的兑换方向,以及是给定输入还是给定输出。两者合并判断得出swap消耗量和输出量到底对应谁。

完成以上计算之后,开始实际转账。
如果计算出amount小于0,意味着池子需要向用户转账,此时会先把代币转给用户,然后再向用户要求补足输入代币。两方完成转账后,池子会做校验要求,如果用户转入的代币数量不够会返回“IIA”,意思是Insufficient Input Amount。

函数执行完成,对外发布事件广播,swap已完成。最后解锁池子,可以接受下一次兑换。
函数flash
这个函数就是大名鼎鼎的闪电贷功能的外部入口:允许用户在一笔交易中借出并归还本金。

入参四个字段,
recipient:接收借出代币的地址;amount0:借出的token1数量;amount1:借出的token2数量;data:回调数据。
这是一个外部函数函数,并且带了lock防重入和noDelegateCall防止委托调用的修饰器。

797行到803行定义了一系列变量。
把当前流动性读到内存中,而且需要保证有流动性,如果流动性为0直接返回“L”,L代表liquidity,就能知道是流动性的问题。
手续费和swap的手续费一致,根据要借出的数量乘以费率算出手续费,调用的函数是mulDivRoundingUp,计算结果会向上取整,确保协议不会因为精度截断而损失。
balanceBefore用于记录借出前的余额。

乐观转账,先将用户请求借出的代币转出,然后回调用户的自定义逻辑,在回调函数中需要把还款和手续费一并支付。
最后require校验池子中两种代币的余额,数量至少要比之前多出手续费的量。

817行计算用户支付的金额,用池子前后的数量做差得出。这里直接用了减法,因为上边已经after要比before大,所以不会向下溢出。
820行计算手续费,feeProtocol0是从slot0.feeProtocol低4位获得协议费的比例。
如果协议费不为0就执行协议费计算,因为协议费以分母的形式表示,10表示1/10,所以额外校验不能为0。
将计算后的协议费累加到protocolFees.token上,并在总手续费中减除协议费的部分,累加到LP归属的手续费上。
最后833行对外发布Flash事件。
函数setFeeProtocol
这是仅限工厂所有者调用,用来设置协议费的函数。

入参是两种token的协议费比例,修饰器有lock防重入,以及onlyFactoryOwner这是确保只有创建pool的factory才能调用。
838行,要求协议费要么是0要么在4至10之间,也就是抽成比例在1/4到1/10之间,避免抽成比例过高,过分挤压LP收益。
842行获取修改前的协议费率,便于emit广播事件中使用。
844行写入新的协议费率,把feeProtocol1左移4位和feeProtocol0加在一起,实现低4位存储token0的费率,高4位存储token1费率。
最后广播事件SetFeeProtocol,同步新旧协议费率。
函数collectProtocol
这是提取协议手续费的函数。

入参是接收手续费的地址,以及希望提取的两种token的数量。出参是实际提取的token数量。这个函数也有onlyFactoryOwner的修饰,意思是只能工厂所有者调用。

853行在做数量校验,确保不会超额提取,代币余额和希望提取数量取小。
857行做了gas的优化,当提取数量恰好等于代币余额时,故意少提取1个单位,留下一点点不至于归零。这是因为,如果为0未来再写入时会触发0->非零的操作,这个动作会额外消耗gas。
确认数量之后执行transfer函数将代币转出。
最后对外广播事件,说明提取人和代币数量。
总结
这是整个UniswapV3中最复杂的合约逻辑。可以支持完成铸造和销毁、限价交易、手续费分配、闪电贷等复杂逻辑。总的来说,用复杂的库做精确计算,用位图管理状态,是一个运算驱动的自主运行的银行。
恭喜我们一起坚持读到这里,了解了UniswapV3最核心的逻辑。 朋友们下次再见!
夜雨聆风