Uniswap v2 学习
swap 的四个参数里没有「你要付多少」。跟着 UniswapV2Pair.sol 走一遍:储备量为什么是 112 位、第一笔流动性为什么烧掉 1000 份、K 检查为什么排在转账后面,以及协议费怎么攒到下一次 mint 才收。
swap 的四个参数是 amount0Out、amount1Out、to、data,全都在说你想拿走什么,没有一个字说你要付什么。
付款得在调用 swap 之前就完成:先把代币转进 pair 合约,再调 swap。合约拿当前余额减去它自己记着的储备量,差额就算你付的钱。这条约定决定了 core 合约其余部分的样子 —— 它不管你怎么把钱送进来,只在函数结束时检查一次「池子里的东西够不够」。下面按 UniswapV2Pair.sol 从上往下读。
三个字段挤在一个存储槽里,读一次储备只花一次 SLOAD
uint public constant MINIMUM_LIQUIDITY = 10**3;
uint112 private reserve0; // uses single storage slot, accessible via getReservesuint112 private reserve1; // uses single storage slot, accessible via getReservesuint32 private blockTimestampLast; // uses single storage slot, accessible via getReserves
uint public kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event
uint private unlocked = 1;modifier lock() { require(unlocked == 1, 'UniswapV2: LOCKED'); unlocked = 0; _; unlocked = 1;}112 + 112 + 32 = 256,正好一个存储槽,源码里那三行重复的注释就是在说这件事。三个字段都是 private,对外只留 getReserves() 这一个一次返回三元组的 getter —— 它们在同一个槽里,一次 SLOAD 就把三个值都拿到了。拆成三个 public 变量看起来更方便,代价是每次要读三次存储。
mint、burn、swap 三个函数的第一行都是 (uint112 _reserve0, uint112 _reserve1,) = getReserves();,后面跟着一句源码自带的注释 // gas savings。之后整个函数体用的都是带下划线的局部副本。储备量在一个函数里要读四五次,读栈和读存储差着两个数量级,这一行省下的是后面那几次。
这个打包不是白来的。uint112 的上限约 5.19 × 10³³,换算成 18 位小数的代币是 5.19 × 10¹⁵ 个 —— _update 里那句 require(balance0 <= uint112(-1), 'UniswapV2: OVERFLOW') 不是摆设,发行量特别大的代币在 v2 里开不了池子。另外,储备量存的是「合约认为自己有多少」,不是「合约实际有多少」;两者可以对不上,这一点后面 skim 和 sync 那一节要用到。
lock 是一把最朴素的互斥锁:进函数时把 unlocked 置 0,出来再置回 1,中途任何一次重入都会撞上 require(unlocked == 1) 直接 revert。mint、burn、swap、skim、sync 全都带着它。这把锁在下面讲 swap 的时候才显出它真正的分量。
第一笔流动性有 1000 份被永久烧掉
function mint(address to) external lock returns (uint liquidity) { (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings uint balance0 = IERC20(token0).balanceOf(address(this)); uint balance1 = IERC20(token1).balanceOf(address(this)); uint amount0 = balance0.sub(_reserve0); uint amount1 = balance1.sub(_reserve1);
bool feeOn = _mintFee(_reserve0, _reserve1); uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee if (_totalSupply == 0) { liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens } else { liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1); } require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED'); _mint(to, liquidity);
_update(balance0, balance1, _reserve0, _reserve1); if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date emit Mint(msg.sender, amount0, amount1);}第 5、6 行就是开头那条约定的落地:存了多少不是参数传进来的,是余额减储备量算出来的。mint 完全不认识你,谁在这之前把币转进来它不关心,函数被谁调用,份额就发给谁指定的 to。直接对 core 调 mint、而转账还没发生,你会拿到零份额;转了账却让别人抢先调用 mint,份额就是别人的。
池子空的时候(第 11 行)份额是 sqrt(amount0 × amount1),用几何平均是为了让份额的数值不随「用哪个币计价」而变。紧跟着的第 12 行把 MINIMUM_LIQUIDITY(1000)铸给零地址,永远取不回来,白皮书给的理由是挡住第一个 LP 把单份流动性做得极贵:如果第一笔只铸 1 份,再往合约里直接转一大笔币,一份的价值就会高到后来者存进去按比例算下来是 0 份。先烧掉 1000 份,这个攻击的成本就翻了一千倍。
池子非空时(第 14 行)取的是两个比例里的较小值。你按 1:1 的价格往一个 1:2 的池子里存,多出来的那一侧不会退给你,也不会多给份额,它就留在池子里成了所有 LP 的公共财产。所以没人直接对 core 调 mint —— UniswapV2Router02.addLiquidity 先按当前储备比例把两边的数量算齐,再把币转进 pair,最后才调 mint,两步在同一笔交易里。
burn 是反过来的一套:amount0 = liquidity × balance0 / totalSupply。注意分子用的是 balance0 而不是 reserve0,源码注释写着 using balances ensures pro-rata distribution —— 有人往合约里白送过的币也会按份额分给退出的人。它同样靠余额判断你销毁了多少:份额得先 transfer 到 pair 自己名下,burn 读的是 balanceOf[address(this)]。
LP 份额本身是个 ERC20,实现在 UniswapV2ERC20 里,UniswapV2Pair 直接继承它。超出标准的只有一个 permit:按 EIP-2612 的格式验一个离线签名,验过就直接 _approve,用户不必先发一笔 approve 交易。撤流动性因此能压成一笔 —— Router02.removeLiquidityWithPermit 拿签名给自己授权,再把份额 transferFrom 进 pair,然后 burn。签名里带着 nonces[owner]++ 和 deadline,前者防重放,后者防一个旧签名躺很久之后被人捡起来用。
swap 先把钱转出去,最后才检查 K
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock { require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT'); (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
uint balance0; uint balance1; { // scope for _token{0,1}, avoids stack too deep errors address _token0 = token0; address _token1 = token1; require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO'); if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data); balance0 = IERC20(_token0).balanceOf(address(this)); balance1 = IERC20(_token1).balanceOf(address(this)); } uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0; uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0; require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT'); { // scope for reserve{0,1}Adjusted, avoids stack too deep errors uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3)); uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3)); require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K'); }
_update(balance0, balance1, _reserve0, _reserve1); emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);}顺序值得停一下:转账在前(12、13 行),一次对任意地址的外部调用在中间(14 行),K 检查在最后(24 行)。这是「检查-生效-交互」的反面,而且是故意的。合约先把你要的币乐观地发出去,再回过头看池子里剩下的东西合不合规矩;不合规矩就整笔 revert,发出去的币一并回滚。
第 14 行那一行就是闪电兑换(flash swap)的全部实现。data 非空时,pair 会在转账之后、检查之前回调 to 地址的 uniswapV2Call。你可以先把币拿走,在回调里做任何事 —— 去别的池子套利、去借贷协议平仓 —— 只要回调返回时 pair 里的余额能让最后那个 K 检查通过就行。还回来的可以是同一种币(那就是一笔借款),也可以是另一种币(那就是一次正常的兑换,只是付款推迟到了收货之后)。
这条路之所以安全,靠的正是上一节那个 lock。回调里的代码是攻击者写的,但它没法重入 swap、mint、burn 里的任何一个 —— 全都被同一把锁挡着。v2 不是靠调整语句顺序防重入的,是靠这把互斥锁;有了锁,语句顺序才敢反着写。
第 18、19 行把「你到底付了多少」倒推出来:当前余额减去「原储备量减掉刚发出去的量」。第 24 行的 1000 和 3 就是那 0.3% 手续费 —— balance0Adjusted = balance0 × 1000 − amount0In × 3,等价于把转进来的部分先扣掉千分之三再参与比较,两边都乘了 1000 所以右边是 1000**2。手续费不单独转给谁,它留在池子里,储备量变大,K 变大,所有 LP 手里的份额跟着值钱。
用同一种币还款的闪电贷因此没有单独的费率:你借走 amount0Out,还回来的必须多到让扣过千分之三之后的乘积仍不小于原来的 K,算下来就是借款额的 0.3%,和一次普通兑换收的是同一笔钱。v2 没有为闪电贷写任何专门的记账代码,它只是 K 检查的一个副产品。
pair 这一侧只做「够不够」的判断,具体该付多少由 periphery 算。UniswapV2Library.getAmountOut 是把 K 不等式解成等式:amountOut = amountIn × 997 × reserveOut / (reserveIn × 1000 + amountIn × 997)。反过来的 getAmountIn 解出所需输入之后有一句 .add(1) —— 整数除法总是向下取整,不补这个 1,算出来的输入会差一个最小单位,pair 那边的 K 检查就会 revert。多收一个 wei,误差归池子。
恒定乘积这条曲线不是 Uniswap 独有的。Perpetual Protocol 二:VAMM里那套 vAMM 用的是同一个 x·y=k,区别在于池子里没有真实资产,只有一条记账用的曲线。
协议费不在每笔交易里收,而是攒到下一次 mint 或 burn
function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) { address feeTo = IUniswapV2Factory(factory).feeTo(); feeOn = feeTo != address(0); uint _kLast = kLast; // gas savings if (feeOn) { if (_kLast != 0) { uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1)); uint rootKLast = Math.sqrt(_kLast); if (rootK > rootKLast) { uint numerator = totalSupply.mul(rootK.sub(rootKLast)); uint denominator = rootK.mul(5).add(rootKLast); uint liquidity = numerator / denominator; if (liquidity > 0) _mint(feeTo, liquidity); } } } else if (_kLast != 0) { kLast = 0; }}协议费默认是关的:feeTo 是零地址时 feeOn 为 false,这个函数什么都不做。打开之后,它收的不是每笔交易的抽成,而是 sqrt(k) 从上一次流动性事件到现在的增长量的六分之一,以新铸 LP 份额的形式发给 feeTo。第 11 行那个 rootK × 5 + rootKLast 的分母就是「稀释到恰好六分之一」解出来的。0.3% 的六分之一是 0.05%,这就是协议费打开后 LP 实际让出的那部分。
为什么用 sqrt(k) 而不是直接数手续费:手续费本来就留在池子里,池子的每一次增长要么来自手续费、要么来自有人存钱,而存钱和取钱的时刻恰好就是 kLast 被刷新的时刻(mint 和 burn 的末尾那句 if (feeOn) kLast = ...)。两次刷新之间 k 的增长只可能来自交易费。于是链上不必为每笔 swap 多做一次转账,只在有人动流动性的时候结一次账。
第 16 行那个 else 分支是个容易漏掉的细节:协议费关着的时候,kLast 被清零。这样治理后来把它打开,收的是从打开那一刻起的增长,而不是把历史上积累的全部一次性收走。
这套「记一个检查点,需要时算差额」的做法在链上到处都是。Perpetual Protocol 三:ClearingHouse里的资金费用是同一个思路的更彻底的版本 —— 那边维护的是一个持续累加的全局量,每个仓位自己记着上次结清时的读数。
预言机只累加价格乘以时间,取平均是读的人自己的事
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private { require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW'); uint32 blockTimestamp = uint32(block.timestamp % 2**32); uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) { // * never overflows, and + overflow is desired price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed; price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed; } reserve0 = uint112(balance0); reserve1 = uint112(balance1); blockTimestampLast = blockTimestamp; emit Sync(reserve0, reserve1);}累加器攒的是「价格 × 秒数」。合约里没有任何地方算平均,也没存任何历史 —— 想要一段时间的均价,读的人自己在 t₁ 和 t₂ 各取一次 price0CumulativeLast,两者相减再除以 t₂ − t₁。窗口多长由消费方决定,代价是消费方得自己找地方存 t₁ 那个快照。
第 7、8 行用的是 _reserve0、_reserve1,也就是这次改动之前的储备量,而新值要到第 10 行才写进去。所以累加进去的是过去那段时间里一直成立的价格,本次交易造成的新价格要等到下一次 _update 才被计入。这一条是它抗操纵的关键:在同一个区块里把价格砸下去再拉回来,对累加器没有任何影响。想推动均值,你得让偏离的价格真的挂在链上跨越若干个区块,而这段时间里套利者会一直在你身上赚钱。
timeElapsed 那行的注释 overflow is desired 是认真的。时间戳被截成 uint32(2106 年会绕回),两个 uint32 相减在 0.5.16 里自然溢出回绕,只要两次调用间隔不超过 2³² 秒,减出来的差值就是对的。累加器本身也允许溢出,同样的道理:读的人只做减法,绕回一圈不影响差值。
UQ112x112 是自己实现的定点数(112 位整数部分、112 位小数部分),因为 Solidity 没有浮点。价格是 reserve1 / reserve0,两边的储备量都是 112 位,商放进 224 位刚好,剩下 32 位留给乘以 timeElapsed 之后的进位。
余额和储备量对不上时,skim 和 sync 各修一边
余额减储备量这条约定留了个口子:任何人都可以直接往 pair 转币,不调用任何函数。合约靠一对函数处理这种漂移 —— skim(to) 把超出储备量的部分转给 to,sync() 反过来,把当前余额直接写成新的储备量。源码里给它们的注释是 force balances to match reserves 和 force reserves to match balances,两句话就说完了分工。
sync 还是一个逃生口。有代币会在转账时对持有者收税或做通缩,pair 的余额会莫名其妙地少掉一块;也有余额被撑到超过 uint112 上限的情况,那时 _update 里的 OVERFLOW 检查会让 mint、burn、swap 全部失效,先 skim 掉多余的再 sync 才能把池子救回来。
pair 的地址是算出来的,不是查出来的
createPair 用 CREATE2 部署,salt 是 keccak256(abi.encodePacked(token0, token1)),两个地址在此之前已经按数值大小排过序。salt 完全由这一对代币决定,不掺时间戳、不掺区块哈希 —— 于是 pair 的地址在合约还没被部署之前就能算出来:
function pairFor(address factory, address tokenA, address tokenB) internal pure returns (address pair) { (address token0, address token1) = sortTokens(tokenA, tokenB); pair = address(uint(keccak256(abi.encodePacked( hex'ff', factory, keccak256(abi.encodePacked(token0, token1)), hex'96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f' // init code hash ))));}这是个 internal pure 函数,没有任何外部调用。Router 要跟哪个 pair 打交道,直接算,不去问 factory 的 getPair 映射。一次跨三个池子的兑换,省掉的是三次 STATICCALL 加三次 SLOAD。
代价写在第 7 行那个硬编码的哈希上:它是 UniswapV2Pair 创建字节码的 keccak256,编译器版本、优化参数、任何一个字节的改动都会让它变成另一个值。分叉项目照抄 v2 源码重新编译之后,这一行必须跟着换,否则 Router 算出来的地址上什么都没有。这类 fork 的第一个线上事故通常就出在这儿。
Router 本身没有状态,只有 factory 和 WETH 两个 immutable 变量,也不留任何代币余额。多跳兑换时它连中转都不做:
function _swap(uint[] memory amounts, address[] memory path, address _to) internal virtual { for (uint i; i < path.length - 1; i++) { (address input, address output) = (path[i], path[i + 1]); (address token0,) = UniswapV2Library.sortTokens(input, output); uint amountOut = amounts[i + 1]; (uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOut) : (amountOut, uint(0)); address to = i < path.length - 2 ? UniswapV2Library.pairFor(factory, output, path[i + 2]) : _to; IUniswapV2Pair(UniswapV2Library.pairFor(factory, input, output)).swap( amount0Out, amount1Out, to, new bytes(0) ); }}第 7 行是这个循环的全部巧思:每一跳的收款地址不是 Router,是下一个 pair,只有最后一跳才发给用户。代币从你的钱包出去之后一路在 pair 之间直接传递,Router 从头到尾没碰过它们。所有数量在进循环之前就由 getAmountsOut 一次算完,循环里不再有任何算价的动作。
这套设计留下的几笔账
core 把安全交给了 periphery。 swap 不检查滑点、不检查 deadline、不认识输入数量,直接对它发起调用而算错一个数,钱就没了。这些检查全在 Router 里,而 Router 是可以被绕过的 —— core 的简单是拿「用错了不管」换来的。
112 位的储备量上限是硬的。 发行量超过 5.19 × 10¹⁵ 个(18 位小数)的代币在 v2 里没法有一个健康的池子,这不是参数问题,是存储布局定死的。
TWAP 把状态推给了消费方。 合约只给累加器,谁想用谁自己存快照、自己定窗口。窗口取短了容易被推动,取长了在真实行情里跟不上,这个取舍 v2 没有替使用者做。
恒定乘积对任何价格都留着流动性。 一个本该锚定在 1 附近的稳定币对,绝大部分资金停在永远不会成交的价位上。v3 的集中流动性和 Curve 的曲线针对的都是这一条。
余额即真相,于是抢跑是免费的。 转账和 mint / swap 是两个独立的动作,只有把它们塞进同一笔交易才安全。任何一个先转账、后在下一笔交易里调用的流程,中间那个空档里谁都能来把钱领走 —— 这不是漏洞,是「不认识调用者、只看余额」这个设计必然的推论。
kLast 只在流动性事件时刷新。 一个长期没人存取的池子,协议费会一直挂着不结算;真到结算那一刻,是当时在场的 LP 一起承担被稀释的那部分,而不是产生这些手续费的那段时间里的 LP。
这篇归在 DeFi 协议下。同一主题里最近的另外两篇是 Bunni DEX 架构设计与代码实现深度技术分析和 Uniswap V4 Hooks 指南。