朋友们大家好,今天我们来看pair.sol,终于进入了uniswapv2 core里最核心的部分。

最开头还是基础动作指定编译器版本,导入需要用到的库和文件,有一些是熟悉的比如erc20、math、factory,还有一些是眼生的比如UQ112x112。

第11行,合约名称叫UniswapV2Pair,关键字is之后跟了一个接口IUniswapV2Pair和一个合约UniswapV2ERC20。Solidity支持多重继承。
UQ112x112
12到13行,是两个using A for B,把A作为操作符应用在B类型上,safeMath在erc20的解读里详细解释过了。我们着重看13行的UQ112x112。

这个库里第一句注释:这是一个解决二进制定点数的库。为什么需要这样一个库,是因为在EVM中没有浮点数,所有的计算都是整数运算,除不尽就会被截断,但是交易、价格又对精度的要求非常高,怎么办呢?如果算除法的时候能够给数字扩大非常多倍,即便被截断精度也够用了。这个库就是在解决这个问题。
库名叫UQ112x112,U是指Unsigned无符号,Q是指Q-format定点数的二进制小数位,112x112是说整数部分占112位,小数部分占112位,所以这是一个224位的无符号整数。
第9行定义了一个uint224类型的constant常量,Q112等于2的112次方。
函数encode,encode是编码的意思,在这的意思就是把输入的y值加工成定义的UQ112*112,这是一个internal pure函数,这意味着这个函数只能内部访问,并且不读写任何状态,只完成算数计算。返回值z是将输入的y值乘上刚刚定义的Q112,也就是把输入数乘上2的112次方之后返回。Q112就是一个缩放因子,任何数乘以Q112就等于左移112位,
翻译成人话就是encode把输入的整数y的小数点向左移了112位,比如encode传入了的y=5,编译器会把 5转换成二进制0b101,乘以2的112次方后,二进制结果就是 0b101 后面跟 112 个0。这个逻辑有点类似把5元钱换算成50角或者500分,用更小的单位做运算算出来的都是整数。
uqdiv是在做除法,入参是一个uint224类型的x和一个uint112类型的y,返回值是uint224类型的z是x除以y的结果。
这两个函数通常是组合使用的,先给分子扩大倍数,然后除以分母,算出来的数就不会被截断失精,而且扩大的倍数是已知且固定的,如果需要还原也能再做计算。

从15到28行,都在定义常量或者变量。
15行是一个constant常量MINIMUM_LIQUIDITY最小流动性,这个字段被直接赋值为了1000,后续用到的时候我们会详细解释为什么要给一个值。
4位字节的私有常量叫SELECTOR,最内层是transfer(address,uint256),这是一个函数签名字符串,是用来唯一标识一个函数的字符串,粗暴理解为不同函数的编号,获取的是这种传入一个地址和金额的transfer函数的编号。keccak256是将它们取哈希值,最外层的bytes4是取结果的前4个字节,把值赋给SELECTOR作为函数选择器。把这个信息存储下来,后边EVM就可以根据这个4个字节确定调用的是哪个函数。
18到20行是3个地址,分别是factory、token0、token1
22到24行是3个变量:reserve0和reserve1,分别对应token0和token1的余额,blockTimestampLast是存储上区块上一时刻时间戳。
在注释里有这样一句话“使用单个存储插槽,可通过getReserves访问”。这三个变量总大小 = 112 + 112 + 32 = 256 位,恰好占满一个 EVM 存储槽,这样读写一次存储槽就能同时操作三个变量,大幅降低 Gas 消耗。
26到28行是三个公共变量,price0CumulativeLast和price1CumulativeLast,分别指token0和token1的累计事件加权价格,后续_update函数中会用到。kLast是上一时刻的K值,k的计算=reserve0*reserve1。
K值是uniswap自动做市商逻辑的核心。简单来说就是在兑换前后要保证池子里两种代币的数量相乘之后是一个定值。这样就不会出现其中一种币被完全兑换完,丧失流动性,并且也可以比较好地完成价格发现。更详细的解释可以看这个介绍没有中间商如何交易。
重入锁

30到36行是在做一个重入锁。
首先定义了一个私有变量叫unlocked,unlocked就是没被锁上的意思,初始赋值是1。也就是最开始的状态是未上锁。
关键词modifier的意思是修饰器。require要求unlocked字段值是1,如果不是的话就返回'UniswapV2: LOCKED'被锁定了。随后将unlocked的状态改为0,从未上锁的状态变为锁定状态。
函数修饰器是函数的可继承属性。注意第34行有一个下划线,继承的函数重写的函数将插入到下划线的位置,在这里执行继承的函数体。不论中间的插入的函数体执行完成或者中途跳出了,只是跳出了插入部分的函数体,下划线之后的内容依然会被顺序执行。所以最后unlocked会被重新赋值为1,等待下次调用。
重入锁是怎么实现的呢?在函数体执行前,检查unlocked是否开启,通过检查之后马上关上,然后开始执行函数,直到完全完成跳出函数体,这时unlocked状态会被重新改写为开启。重入锁的主要作用是防止递归调用,函数在未解锁前不能二次进入。在后续的mint、burn、swap、skim、sync函数中都能看见lock修饰器,只需要添加lock修饰器就行,不需要反复处理重入防护逻辑。
getReserves

38行,函数叫getReserves,返回3个参数,上一时刻2个token的余额和上一时刻区块时间。这个函数的意图也很容易理解,就是字面意义上的获知两个代币各自的余额和对应的时间。
_safeTransfer

44行,函数叫_safeTransfer,入参是token、转入地址和金额。函数体的主要内容在45行,等号右侧手工构造了一笔合约调用。SELECTOR是16行已经定义过的常量,相当于告知evm调用transfer(address,uint256)这个函数,紧跟着的通过入参传入的to地址和value金额对应了SELECTOR中transfer函数的两个参数,abi.encodeWithSelector把函数选择器SELECTOR和对应的两个参数按照ABI编码规则组成了一段calldata。再外一层是call函数,通过这个函数发送给目标合约,目标合约是谁呢是传入的token。这句话连起来就是执行把token向to地址转账value个。等号左侧的success负责接收是否成功执行的结果,如果合约返回了数据字段data负责接收。
46行的require负责检查状态,首先success必须为真,其次data为空或者data解码为真,如果条件不达成则返回'UniswapV2: TRANSFER_FAILED'交易失败。
为什么要大费周章地做_safeTransfer,不能直接调用标准transfer函数呢?主要是因为混乱的erc-20生态,转账成功与失败不同代币的返回值以及data的情况各不相同,用底层的call直接调用函数,并且手工解析返回值,能最大程度兼容各种情况。

49到59行,是四种事件,分别对应mint、burn、swap、sync,当对应函数执行完,会对外发布事件告知结果。

第61到70行,在做构造函数和初始化。
构造函数中只有一行,将函数调用方的地址赋值给factory。
在初始化函数中,传入token0和token1,require要求调用initialize函数的地址必须和调用构造函数的地址是同一个地址,才能完成初始化,否则返回'UniswapV2: FORBIDDEN'禁止。接着把传入的2个token赋值对应的token0和token1,这部分和上一篇factory中createPair就连上了。传入2个token,完成了对应pair地址的创建。
_update

第73行,函数叫_update,从注释的文本可以看出来,这是在更新代币对的余额以及在每个区块第一次调用时更新价格累计器。
入参是4个数,2个uint类型的balance0和balance1,2个uint112类型_reserve0和_reserve0。balance是代币当前的实际余额,已经包含了用户转入的量,_reserve是上一时刻合约记录的代币对的余额。
首先做条件判定,要求两个balance都比最大的uint112要小,因为_update函数的目的之一是更新reserve的数值,如果超过reserve的最大值,就没办法更新了,就会返回'UniswapV2: OVERFLOW'溢出。
block.timestamp是获取当前区块的时间戳,翻译成时间戳是因为stamp是邮票的意思,如果你寄过信就会知道邮戳是盖在邮票上的,会有盖章日的时间,时间戳就像邮局在邮票上盖章一样印一下,留下当时的时间。
blockTimestamp这个变量被声明为uint32位的,所以在区块的时间戳做了2^32次方取余的计算,只要低32位。因为后续这个时间还会赋值给blockTimestampLast,而blockTimestampLast也是uint32位的,要求是32位是因为在24行声明变量的时候,32位+2个112为恰好256位可以一次性写入。
76行在算时间的增量,用当前时间戳减上一时刻,算出来的结果赋值给timeElapsed,这里在注释中有一句话overflow is desired,为什么溢出是被期望的呢?
我们详细来看时间差是如何被计算出来的
我多解释一句,为什么对时间戳做2^32次方取余的计算,能获得低32位。用十进制举例子比较容易理解:有一个数字是13254,如果我需要它的低2位,也就是最后两位,我应该做的事情是对它做10^2取余,也就是除以100看余数是多少,13254=132*100+54;如果需要低3位,就做10^3取余。同样的逻辑放在2进制上,对时间戳做2^32取余,获得的就是它的低32位。

如果不希望溢出,用uint256直接做减法会更加准确,但那样就需要存储完整的时间戳,利用uint32溢出回绕的特性,就能以更低的成本算出正确的时间差。
77行在做if判断,条件是时间差大于0并且两个代币的余额都不为0,满足条件将进入代币的价格累计计算。先看第79行。a+=b等价于 a=a+b,UQ112x112是上边我们解释过的一个定点数库,encode将整数左移112位,uqdiv返回UQ112x112.encode(x)/y的商。
UQ112x112只是为了做高精度的计算,如果我们暂时忽略这个,这行代码等价于
token0价格累计=token0价格累计(上一时刻)+token1余额/token0余额*时间差
这是在做什么呢?当池子里只有两种代币的数量,而没有价格的时候,代币的价格可以通过数量相除计算出来,有点类似“汇率”的概念,把价格*时间再加起来,做的就是用时间加权算了价格的累计。这样做的用处是什么?如果我们知道两个时刻点,以及这两个时刻对应的价格累计,就能计算出对应时刻的价格。而且这样是在用时间给价格加权,操纵价格就变难了,短时间操纵价格对整体的价格累计影响很小,除非长时间影响价格,这样作恶者的成本也增加了。
再解释一下,注释里写的乘法不会溢出,而加法的溢出是预期内的。
定点数计算出来的价格最大值是224位的,时间差是32位,它俩乘完也在uint256的范围里,所以乘法不会溢出。
price0CumulativeLast价格累计是uint256格式的,随着时间的累加它最终会有一天超过最大值,发生自然回绕,但后续使用中用的是两个时间点之间的差值,和上边提到的计算时间差的逻辑类似,做模算术只要差值小于2^256,就不影响差值计算。
82行将balance0赋值给reserve0,就是用最新的余额覆盖上一时刻的余额。
84行把blockTimestamp赋值给blockTimestampLast,没有人永远年轻啊
最后向外发布sync事件,更新后池中两个代币的数量分别是reserve0和reserve1。
_mintFee
这个函数在计算的协议费

入参是两个代币的余额,出参是返回一个布尔值是否开启协议费收取。
90行,定义了一个feeTo,看过Factory代码里,feeTo()是在设置接收手续费的地址
接着在给feeOn协议费是否开启赋值,如果feeTo已经被设置好了不是0地址,那么feeTo!=address(0) 为真,feeOn被赋值为1,如果feeTo仍然是默认的0地址从未被设置过,那么feeTo!=address(0) 为假,feeOn被赋值为0不开启协议手续费。
kLast是第28行定义的上一时刻的K值,在这里把值赋给_kLast,是因为读取局部变量比读取状态变量消耗的gas费要少。
如果feeOn为1,也就是协议手续费是开启的,继续向下执行;否则跳出,也就是说如果这个pair池没有开启收取协议费,就不用计算协议费了。
注释里写协议手续费是根号k增量的1/6,我们详细看看具体是怎么实现的。
先判断kLast是否不为0,如果为0说明这是pair池创建后的第一次操作,还没有历史累计,就算不出k的增量,因此跳过。只有在协议费开启并且klast不为0的时候,才能计算协议手续费。
定义变量rootk,rootk就是根号下k,rootk=Math.sqrt(uint(_reserve0).mul(_reserve1)),是基于传入的两个代币的最新余额计算出。rootKLast根据名字能知道这是上一时刻的rootK,直接对kLast算根号。
接着97行,对rootk和rooklast做判断,需要rootk比rooklast大,这就意味着k要比kLast大。这有新的问题了,k不是一个常数吗,自动做市商的逻辑建立在reserv0*reserve1=k,k保持不变才能让token0和token1在同一条双曲线上,能够快速发现价格,并且自动完成做市。为什么k会变大?K不变的前提有两个,一是没有铸造和销毁的外力,只做两个代币的兑换的时候,两个代币的数量的乘积是一个固定的常数k。但是当有人为池子注入了新的流动性,两种代币的数量都增长了,对应K的数值也会相应增长。
二是手续费本身也会使得K增长。
A愿意把代币投到池子里提供流动性,最本质的原因是有利可图,别人用A的钱做兑换,A可以收到手续费,通常交易手续费的比例是0.3%。这0.3%的手续费会留在合约中,会让k变大。我们举个例子:
假设一个 USDT/ETH 池子,当前储备量:
reserve0 = 100USDT
reserve1 = 1 ETH
K = 100 × 1 = 100
现在有人想用 10 USDT 兑换 ETH。
如果没有任何手续费(0%)
恒定乘积要求交易后的 k 不变。 交易者存入 10 USDT,池子 USDT变成 110。为了保持 k=100,ETH 储备必须变成 100 / 110 ≈ 0.909 ETH。 交易者会收到 1 - 0.909 ≈ 0.091 ETH。 乘积仍然是 110 × 0.909 ≈ 100,k 没变。
有 0.3% 手续费
Uniswap V2 实际会收取 0.3% 的手续费,意味着用户支付的 10 USDT 中,只有 10 * (1 - 0.3%) = 9.97USDT会用于交换,剩下的 0.03 USDT直接留在池子里不参与定价。
因此:
池子实际收到 10 USDT,余额变为 110 USDT。
但计算输出量时,合约只假装收到了 9.97 USDT。
恒定乘积计算:新 ETH 储备 =
100 / (100 + 9.97) ≈ 0.9092ETH。用户实际收到
1 - 0.9092 = 0.0908ETH。
此时:
新 k = 110 USDT × 0.9092 ETH ≈ 100.012
K 从 100 增长到了 100.012,变大了。这多出来的 0.012 对应的就是0.3%的手续费。
手续费带来的k的增长会留在合约中,对应的表现就是LP代币越来越“贵”,LP升值的部分就是流动性提供方获得的利润。
我们回到mintfee函数,协议费是交易手续费(0.3%)的 1/6,也就是每笔交易额的 0.05%。这部分由项目方(feeTo 地址)拿走,其余 5/6 归流动性提供者。项目方拿走利润的方式是铸造出这部分利润对应的LP代币,所以这个函数叫mintFee,铸造协议手续费。
函数核心在解决的问题是:当k增长之后,应该铸造多少LP恰好对应应该拿走的1/6,而不过度稀释LP的价值。毕竟当总价值不变的时候,铸造更多的LP代币,意味着每个LP代币背后对应的价值就变小了。
接下来是求解一个一元二次方程。
设:应该额外铸造x个LP代币,用做协议手续费
原有LP持有者应该获得的收益对应出来是:由于手续费积累带来的√k的增量的5/6,我们用rookLast和rootK分别代表增长前的√k和增长后的√k。
所以原有LP持有者应获得的收益是=rookLast+(5/6)*(rootK-rookLast)
解方程需要一个等式,等式另一边是这些收益对应多少LP代币
当前LP的总供应量是TotalSupply,协议手续费要额外铸造x个,所以铸造后的数量是(TotalSupply+x)个,原LP持有者按照比例能获取的价值是(TotalSupply/(TotalSupply+x))*rootK
解完方程应该额外铸造的数量x就是通过98至100行算出来的liquidity,如果这个liquidity>0,就完成铸造,铸造出的LP会发送到feeTo地址,就是接受手续费的地址。
mint

110行的mint函数,入参是一个地址to,也就是铸造的代币要发送到哪个地址去,这是一个external外部的函数,这里还有一个lock,就是上边分析过的重入锁,返回值是一个uint类型的变量叫liquidity,liquidity的意思是流动性,就是本次铸造了多少个LP代币。
LP是Liquidity Provider的缩写,你往pair池内注入流动性,就是比如你放入一定量的usdt和eth供别人兑换,你就是流动性提供者,怎么证明池子里有你的代币呢?在你把token放入pair的时候,铸造一定量的LP代币给你,作为“存款凭证”,UNISWAPv2的LP代币叫UNI-V2,这就是erc20.sol文件里做的基础工作。
mint函数就是在用户为池子注入流动性之后,算出来应该铸造多少LP代币,并完成铸造和转账。
我们看具体的函数体实现。
111行首先获取上一时刻池子里两种代币的余额,并且赋值给_reserve0和_reserve1,声明一个局部变量并且写入,主要是为了省gas费。
112到113行在获取当前地址里token0和token1的数量,把数值赋给balance0和balance1,balance是池子中最新的代币数量。balance-_reserve的差值就是由于用户新注入或者这段时间由于交易产生的代币数额差。
117行是在看当前这个池子是否开启了协议费。并把值赋给feeOn。
totalSupply的含义是总供应量,这个总供应量指的是LP代币的总供应量。随着池子中两种代币的数量越来越多,也就意味着更多的人在提供流动性,那么就应该发行更多的LP代币凭证给用户。totalSupply也是累加的过程,随着每次注入流动性,铸造出新的LP,然后把新铸造的LP数量加在LP的总供应量上。
当最开始_totalSupply为0的时候,说明这是首次添加流动性,用户已将代币转入合约,amount0 和 amount1 即为首次存入的全部数量。此时 liquidity = √(amount0 * amount1) - MINIMUM_LIQUIDITY,也就是 √k - 1000。minimum_liquidity是一个定义的常量1000,主要目的是限制不让最开始注入的流动性过小,首次铸造时会先铸造1000个并永久锁定,对应121行的逻辑。minimum_liquidity的值通常非常小可以忽略,所以首次铸造之后liquidity=TotalSupply=√k
如果不是首次铸造,那么本次应该铸造的LP数量liquidity是多少呢?有一个大的原则,不管你什么时候注入流动性,每一个LP背后代表的池子中的代币数量是一样的。所以只要第一次注入流动性之后,后边就好算了,跟第一次的价值一样就行。按token0计算:liquidity0=amount0*_totalSupply / _reserve0,表示这次新注入的amount0相当于池子中reserve0的比例,对应着应该有TotalSupply多少的比例。同样的逻辑再按照token1算一遍,然后两者取小的值。两者取小值也是强制要求用户的注入按照当前池子的比例执行,避免比例失衡。
算出来应该铸造多少LP之后,125行做了require判定,算出来的liquidity是否>0,如果不满足意味着本次铸造是0,就不用做了,直接INSUFFICIENT_LIQUIDITY_MINTED流动性不足以铸造。当liquidity大于0,调用_mint(to, liquidity),在erc20里我们分析过这个函数,实现TotalSupply的增加和to地址代币的增加。
铸造之后,会更新当前池子的代币数量,调用_update函数更新余额和价格累计器。
129行在更新kLast,feeOn表示是否开启了协议手续费,如果开启了协议手续费,则更新kLast的值,因为添加了流动性,K值增长了,会影响协议手续费的计算。详细计算会在_mintFee里介绍。
最后向外发布事件日志,消息调用方注入了amount0个和amount1个代币到池子中。
burn

从133行开始是burn函数,大逻辑和mint类似。burn函数要解决的问题是,要算出来销毁LP需要从池子里取出多少token0和token1还给用户。
首先是获取上一时刻和当前时刻池子里各代币的数量,以及要销毁的LP数量。 142行依旧是获取feeOn,是否开启协议手续费,对应着决定了154行是否需要计算kLast,更新出最新的K值。
计算要销毁的LP数量在TotalSupply中的占比,用这个比例分别乘token0和token1在池子中的数量,计算出要销毁的LP对应的token0和token1的amount。
146行require判定amount0或者amount1任一>0,如果这俩都等于0就没必要做销毁还钱的动作了。满足判定标准会调用_burn将要销毁的LP数量liquidity转到address(0),同时把计算出的amount0和amount1转到用户地址。转账后更新池子余额,如果协议手续费开启了更新K值,最后对外同步Burn事件。
swap

159行开始是swap函数,这个函数看名字就是一个非常核心的函数,毕竟这个项目叫uniswap,主要就是完成兑换功能。入参是4个参数,分别是amount0out、amount1out、转出地址to和传入信息data。
先做require判定,amount0out或者amount1out得有一个大于0,否则返回UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT,两种代币的输出总得有一个大于0,不然兑换啥呢。接着获取池内两种代币的余额,要求两种代币的输出金额要小于池内剩余的金额,不然就不够换的,输出'UniswapV2: INSUFFICIENT_LIQUIDITY'流动性不足。
定义4个局部变量,其中_token0和_token1被额外用花括号包裹起来了,因为在Solidity中,每个函数内使用的局部变量的数量受EVM栈深度限制,最多16个槽位,如果变量太多编译时会报错Stack too deep,在注释中也做了说明。
169行的require判定要求to地址不能等于token0或者token1,否则报错返回'UniswapV2: INVALID_TO',兑换地址不能是两个token中的任意一个地址。
170-171是两行乐观转账,也就是先给钱再校验,与传统的先存后取逻辑不同,乐观转账会先按照用户要求把代币发送出去,然后再检查用户是否存入了足够的输入代币。这样做是为了支持闪电贷,用户可以在收到代币,在同一笔交易内完成套利并归还。
172行是闪电贷回调,如果data不为空,说明用户额外附带了一些回调数据,它希望触发闪电兑换流程。if之后是对to地址合约的外部调用,把170-171行乐观转账的金额作为输入以及用户自己附加的数据data一起透传给回调合约,具体这里会执行什么要看用户的设计,这里的回调机制允许外部合约嵌入自定义逻辑,而uniswapv2只负责代币转移和最终的校验,不限制用户如何使用借出的代币。这样就允许用户在没有资金准备的情况下,借出池子的代币,完成一系列套利之后,最后在同一个交易内归还。
balance0和balance1是在获取池子中当前最新的余额,此时回调已经执行完毕,用户或者是闪电贷调用者应该已经归还了应付的代币。
176至182行在校验用户还的钱够不够。
首先计算净存入多少,用当前最新余额(balance)和上一时刻reserve借出amount后,两者比较,差额就是归还金额。两种代币里至少要存入一种,否则就返回UniswapV2: INSUFFICIENT_INPUT_AMOUNT,还得钱不够,只出不进协议不用玩了。
接着计算付的钱够手续费吗?我们把180-182行的代码翻译成公式:
(balance0-fee0)*1000*(balance1-fee1)*1000>=reserve0*1000*reserve1*1000
其中fee0=amount0In*0.3%,fee1=amount1In*0.3%
这个式子是在要求还完钱、付完手续费之后,k值保持不变,手续费是按照归还金额的千分之三收取。如果还钱少了会返回'UniswapV2: K'提示不满足恒定乘积做市商规则。
最后更新池中代币数量和价格累计器并且向外同步swap日志。
skim

skim在英文中意为"撇去、捞走",这里表示把合约中多出来的代币"撇走"转给指定地址,让余额与记录的储备量重新对齐。
这个函数是一个“清理”功能的函数,避免有人绕过mint函数直接向合约池转入代币,导致实际代币数量比记录值要多。它的工作就是看最新代币数量应该是多少,然后把多余的部分转出到指定地址。里边的内容很简单,就不赘述了。
sync

sync是synchronization同步的缩写,它完成的功能和skim是相反的,skim是看池子的金额是不是比记录多,多的部分不要了。sync反过来,看池子里的金额如果不一样,实际余额比记录多,它选择承认这笔"意外之财",直接更新记录。你看函数体,实际上它调用的是_update()函数,根据池子里的金额来更新最新的reserve。
反正账总能调平,需要归还施主还是拾金就昧取决于需求。
到这里uniswapv2core代码库里的全部代码就看完了,对于什么是恒定乘积做市商以及如何收取手续费、如何收取协议费、如何完成闪电贷、什么是LP都略知一二。
这篇文章非常长,坚持看到这里的都是勇士!
下一次我们会继续看uniswapv2的periphery代码,下次再见。
夜雨聆风