
我从个人经历出发做链上数据分析和套利策略时第一件事就是把Uniswap V2 core源码完整读一遍。读之前我以为自己懂恒定乘积做市读之后才发现真正的控制逻辑全藏在几个核心函数和require断言里。这篇就按我自己的阅读路线把Uniswap core源码里的关键设计逐一拆开讲重点说明代码为什么这么写以及业务逻辑在代码里的真实落点希望对正在啃这套合约的读者有帮助。1. 先从整体结构入手Factory和Pair的分工决定了阅读顺序1.1 v2-core的代码构成两个合约撑起全部核心逻辑Uniswap V2的core仓库源码体量并不大核心合约就两个UniswapV2Factory.sol和UniswapV2Pair.sol。很多人一上来就扎进Pair合约结果被mint、burn、swap、update这些函数绕晕。我的建议是先花10分钟看Factory再进Pair顺序不能反。Factory承担的角色是交易对工厂任何人可以调用createPair创建一个新的交易对Factory负责部署Pair合约并在内部维护getPair(tokenA, tokenB)映射记录所有已创建的交易对。这里有个值得注意的细节Pair地址并不是通过new随机生成的而是用CREATE2预先计算出来的所以任何人都能在交易对正式创建前通过pairFor方法算出它的地址。这个设计的意义在于——路由合约不需要等待交易对创建就能提前组合调用逻辑gas也能省不少。function createPair(address tokenA, address tokenB) external returns (address pair) { require(tokenA ! tokenB, UniswapV2: IDENTICAL_ADDRESSES); (address token0, address token1) tokenA tokenB ? (tokenA, tokenB) : (tokenB, tokenA); require(token0 ! address(0), UniswapV2: ZERO_ADDRESS); require(getPair[token0][token1] address(0), UniswapV2: PAIR_EXISTS); bytes memory bytecode type(UniswapV2Pair).creationCode; bytes32 salt keccak256(abi.encodePacked(token0, token1)); assembly { pair : create2(0, add(bytecode, 32), mload(bytecode), salt) } IUniswapV2Pair(pair).initialize(token0, token1); getPair[token0][token1] pair; getPair[token1][token0] pair; }注意sortTokens这一步合约内部把两个token按地址大小排序小的叫token0大的叫token1。排序不是可有可无的规范而是为了让同一个交易对无论用户传入的token顺序如何都映射到同一个Pair地址。这也是为什么你在Uniswap前端看某个代币的地址有时它排在WETH前面有时排在后面完全取决于地址数值大小不是项目方定的优先级。1.2 储备金模型reserve是快照不是实时余额进入Pair合约前必须建立一个认知Pair合约里的reserve0和reserve1并不是当前合约地址上的token余额而是上一次_update执行时记录的余额快照。真正实时的余额要用IERC20(token0).balanceOf(address(this))去获取。这个区别极其重要。Uniswap里面的所有定价、滑点计算、k值校验全部基于这个快照而不是实时余额。代码里getReserves()返回的就是三个变量reserve0、reserve1、blockTimestampLast。任何一次交易mint、burn、swap、flash结束前合约都会调用_update把所有状态刷新一遍。这样设计的核心原因是链上合约无法每时每刻自动跟踪外部代币转账必须在每次交互结束时主动记账。变量含义获取方式reserve0 / reserve1上一次交易结束时的token储备快照getReserves()balance0 / balance1合约当前实际持有的token余额IERC20.balanceOfblockTimestampLast上一次记账时的区块时间戳getReserves() 返回值第三项明白这个区别之后你再看Pair合约里的每个函数都会发现同一个模式先取快照再取实时余额用两者差值算出本次进来了多少完成业务逻辑后再_update刷新快照。1.3 三个前置知识uint112、精度、lock修饰符读Uniswap V2 core源码代码本身不难难在几个基础概念如果不清楚看代码就会卡壳。第一个是uint112。Pair合约把reserve声明为uint112也就是最大能存约5.2×10³³的数值。之所以不用uint256是因为Uniswap需要用uint224来存累积价格两个uint112拼在一起正好是224位剩余的32位留给了blockTimestampLast。这种按位分配的存储优化在gas费昂贵的链上环境里属于常规操作理解这点能避免你觉得为什么不用uint256。第二个是精度处理。合约里的价格计算用到了UQ112x112格式一个数左移112位把小数部分也编码进整数里。这样在Solidity没有原生浮点类型的情况下仍然能保持足够的计算精度。第三个是lock修饰符。Pair合约从构造函数开始就加了一把锁所有外部可调用函数都必须经过lock检查防止重入。这把锁本质上是一个状态变量_lock进函数前要求它必须是0进入后立刻置为1函数结束前再恢复为0。modifier lock() { require(_lock 0, UniswapV2: LOCKED); _lock 1; _; _lock 0; }之所以所有函数都统一加这把锁是因为流动性提供、交易、闪电贷都会同时涉及多个token的转账如果允许重入攻击者可以在状态尚未更新的情况下反复套利。锁的存在让整个合约的函数调用变成不可重入的原子操作。2. 恒定乘积公式在Pair合约里的真实落地方式2.1 mint函数新增流动性时LP份额是怎么算的mint函数对应的是添加流动性这个核心业务。外部流程是用户先把两种token转到Pair合约地址然后调用mint。合约收到token后在函数内部计算应该给用户铸造多少LP代币。function mint(address to) external lock returns (uint liquidity) { (uint112 _reserve0, uint112 _reserve1,) getReserves(); 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; if (_totalSupply 0) { liquidity Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); _mint(address(0), MINIMUM_LIQUIDITY); } 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); }这里有两个关键逻辑。第一首次添加流动性时LP数量 sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY其中MINIMUM_LIQUIDITY是硬编码的1000。也就是说第一个流动性提供者会永久损失1000个最小单位的LP代币这1000个LP被直接铸造到零地址。这部分我后面单独展开讲。第二非首次添加流动性时LP数量 min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1)。这个公式的本质是新LP份额要同时参照两种token的边际贡献取较小值避免用户只投入一种token来稀释现有流动性。如果某一种token的投入比例和现有储备不成比例多出来的部分不会计入LP份额但会留在合约里——相当于捐给了所有LP。_mintFee是这里容易被忽略的隐藏逻辑如果Factory设置了feeTo协议手续费接收地址合约会在每次mint/burn时检查累积的k值增长按增长量铸造一部分LP代币发送给feeTo。这就是协议费用的实现方式——不是直接扣钱而是通过增发LP份额。2.2 burn函数销毁LP后按比例赎回两种tokenburn对应移除流动性。用户先把LP代币转到Pair合约地址然后调用burn。合约根据LP占totalSupply的比例计算应该转出多少token0和token1。function burn(address to) external lock returns (uint amount0, uint amount1) { (uint112 _reserve0, uint112 _reserve1,) getReserves(); address _token0 token0; address _token1 token1; uint balance0 IERC20(_token0).balanceOf(address(this)); uint balance1 IERC20(_token1).balanceOf(address(this)); uint liquidity balanceOf[address(this)]; bool feeOn _mintFee(_reserve0, _reserve1); uint _totalSupply totalSupply; amount0 liquidity.mul(balance0) / _totalSupply; amount1 liquidity.mul(balance1) / _totalSupply; require(amount0 0 amount1 0, UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED); _burn(address(this), liquidity); _safeTransfer(_token0, to, amount0); _safeTransfer(_token1, to, amount1); _update(balance0.sub(amount0), balance1.sub(amount1), _reserve0, _reserve1); }很多人会发现这里计算用的分子是balanceOf[address(this)]也就是合约自己持有的LP数量而不是用户传入的LP数量。原理和mint一样Pair合约不信任调用者传参数只信任合约自身状态。用户必须先转账LP到合约地址合约再根据自己账户上的LP增量来计算赎回数量。2.3 swap函数k值校验和手续费扣除的位置swap函数是整个合约最核心的部分也是数学细节最多的部分。它先按amount0Out和amount1Out把token转出去然后通过余额差反推本次实际进来了多少token最后校验恒定乘积约束是否被满足。function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock { ... uint balance0 IERC20(token0).balanceOf(address(this)); uint balance1 IERC20(token1).balanceOf(address(this)); if (balance0 _reserve0 || balance1 _reserve1) revert(UniswapV2: INSUFFICIENT_LIQUIDITY); ... 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); 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); }先说amount0In的计算。它不是直接读调用者传进来的参数而是用转账完成后的实时余额减去转出后剩余的快照值来推算输入量。这个设计的妙处在于即使调用者没有显式传入输入数量合约也能算出真实的输入量。后面讲闪电贷时你会发现这个余额差计算是整个闪电贷机制能成立的前提。再看手续费。balance0Adjusted balance0 * 1000 - amount0In * 3这行代码实际上是把0.3%的手续费以内联数学的方式嵌进了k值校验。假设用户买入amount0Out时支付了amount0In数量的token0那么balance0 * 1000 - amount0In * 3就相当于(转入后的真实余额 - 手续费)*1000。两边都做完同样的调整后合约要求调整后的乘积不小于原储备乘积才能保证做市商不因这笔交易亏钱。如果去掉*1000和*3这两个魔数公式变成balance0 * balance1 reserve0 * reserve1那就是完全不加手续费的恒定乘积校验。加入*3之后等于给输入量打了99.7折也就是只要求用户在扣除手续费后的净输入满足乘积约束。这个常数为什么是3而不是30纯粹是因为用1000做分母0.3%正好对应千分之三。3. 容易被忽略却极其重要的隐藏细节3.1 为什么要把1000个LP份额永久锁进零地址第一次添加流动性时代码强制liquidity sqrt(amount0 * amount1) - 1000并把1000个LP铸给address(0)。很多人以为这只是一个象征性操作其实它有明确的数学意图。如果第一个流动性提供者可以拿到全部LP份额而池子刚开始又没有足够的交易深度攻击者有一种攻击手法先添加少量流动性然后用极端价格兑换把池子里的token抽干再用极低的价格添加流动性让LP份额对应的价值趋近于零。锁住1000个单位的LP等于给池子设了一个不可赎回的底仓让第一个LP永远无法通过移除流动性把池子掏空。还有一个更实际的原因如果没有MINIMUM_LIQUIDITY某个流动性提供者理论上可以在池子容量极小的时候用极小的成本持有很多很多LP份额导致后来的LP份额被稀释到无限小。锁定1000相当于强制每个交易对在一开始就有一部分永久锁定的流动性无论谁提供流动性都无法独占全部的池子所有权。3.2 sync函数纠正失衡状态的特殊操作Pair合约里有一个sync函数作用是把reserve快照直接同步为当前余额。看起来很简单但它是防御外部代币异常转账的重要工具。function sync() external lock { _update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1); }实际场景里有些代币自带转账手续费比如每笔转账扣5%。这类代币进入Uniswap池子后合约实际收到的余额比理论值少导致reserve快照和真实余额出现偏差。当偏差持续累积时k reserve0 * reserve1已经不能反映真实池子状态套利者可以用极小的滑点把池子搬空。sync允许任何人在发现偏差时把reserve重新对齐到实际余额相当于给池子做了校准。不过sync本身也带来了新的套利空间如果reserve被人为调高交易者可以用低于正常价格的滑点买入代币所以sync只是应急工具不是常规操作。正常代币交易过程不会用到它它存在的意义是应对那些非标准ERC20代币。3.3 精度截断和Rebase类代币带来的坑Uniswap V2的Pair合约使用uint112存储reserve所有计算都有精度截断。比如在swap中amount0In是通过余额差反推的如果代币精度很低比如只有6位小数小额高频交易会导致大量精度损失累积起来可能被利用。社区里有过相关讨论结论是Uniswap V2并不适合超低精度或通缩型代币每个池子建立前都需要评估代币特性。另外如果你的token是一次性把余额转进Pair合约不是先调approve再调transferFrom那么balanceOf(address(this))得到的结果天然包含外部转账和内部业务逻辑的所有影响。这也是为什么Pair合约的所有计算都用balanceOf差值而不是记录我预期收到多少。这种设计最大的好处是简洁和抗篡改坏处是遇到动态余额代币Rebase类时reserve快照和真实余额会系统性偏离最终导致套利窗口异常。对于这类代币Uniswap V2并不友好Uniswap V3依然如此项目方如果要支持Rebase代币通常需要自己包一层代理合约来转换余额口径。4. 闪电贷和价格预言机不看源码很难理解的进阶机制4.1 flash函数先用后还的约束条件Pair合约里有一个flash函数可以直接借用池子里的全部流动性只要在同一笔交易内归还本金加手续费即可。这个机制在链上被称为闪电贷最大的特点是不需要抵押物因为整个借款和还款都在同一个原子交易里完成只要任何一步失败整笔交易回滚。function flash(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock { ... uint _balance0 IERC20(token0).balanceOf(address(this)); uint _balance1 IERC20(token1).balanceOf(address(this)); require(_balance0 amount0Out _balance1 amount1Out, UniswapV2: INSUFFICIENT_LIQUIDITY); if (amount0Out 0) _safeTransfer(token0, to, amount0Out); if (amount1Out 0) _safeTransfer(token1, to, amount1Out); if (data.length 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data); uint balance0 IERC20(token0).balanceOf(address(this)); uint balance1 IERC20(token1).balanceOf(address(this)); uint amount0In balance0 _balance0 ? balance0 - _balance0 : 0; uint amount1In balance1 _balance1 ? balance1 - _balance1 : 0; require(amount0In 0 || amount1In 0, UniswapV2: INSUFFICIENT_FLASH_LOAN); uint balance0Adjusted balance0.mul(1000).sub(amount0In.mul(3)); uint balance1Adjusted balance1.mul(1000).sub(amount1In.mul(3)); require(balance0Adjusted.mul(balance1Adjusted) uint(_balance0).mul(_balance1).mul(1000**2), UniswapV2: K); _update(balance0, balance1, reserve0, reserve1); }读这段代码时关键点在于合约先把token转出去然后回调uniswapV2Call整个借用期间合约不检查调用者是否有还款能力。还款是否到账完全由回调函数结束后的k值校验决定。如果调用者在回调里没有把本金加手续费补回来最终balance0Adjusted * balance1Adjusted会小于_balance0 * _balance1 * 1000²交易直接revert。还有一个容易被忽略的点flash函数和swap函数其实共用同一套k值校验逻辑。所以在Uniswap V2里闪电贷本质上就是把池子掏空再填回去的特例swap通过data参数传入非空数据时也会触发回调效果和flash几乎一样。社区里常说的闪电交换flash swap指的就是这种机制它不是独立于AMM之外的基建而是AMM本身自带的能力。4.2 uniswapV2Call回调调用方需要实现的约定接口uniswapV2Call不是Pair合约里的函数而是调用方合约需要实现的接口。当你要发起一笔闪电贷时你的合约需要实现uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data)在这个函数内部完成你的套利/清算逻辑并且归还token。归还方式可以是直接transfer给Pair合约也可以在函数内部先通过transferFrom从你的合约转过去。特别注意闪电贷的手续费标准和普通交易相同也是0.3%。但是flash函数里的回调是在转账之后才执行的如果你在回调里只归还本金忘记手续费k值校验会失败。如果你的套利利润连手续费都覆盖不了这笔闪电贷就不该发起。4.3 累积价格的存储TWAP预言机的数据基础Pair合约的_update函数不只是刷新reserve它还会在每次记账时累加一个价格时间积分。相关的状态变量是price0CumulativeLast和price1CumulativeLast它们的含义是每区块价格 × 时间间隔的累计值。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; if (timeElapsed 0 _reserve0 ! 0 _reserve1 ! 0) { price0CumulativeLast uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed; price1CumulativeLast uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed; } reserve0 uint112(balance0); reserve1 uint112(balance1); blockTimestampLast blockTimestamp; }price0CumulativeLast可以理解为token0价格的时间加权累计值。要算某段时间的平均价格只需要记录两个时间点的累计值做差后除以时间差TWAP (priceCumulative_now - priceCumulative_before) / (time_now - time_before)这种先累计、后差分的模式是链上实现TWAP预言机的经典做法。之所以不用当前价格直接读取是因为瞬时价格很容易被大额交易操控而TWAP需要跨越多个区块单笔交易很难长期影响平均值。4.4 为什么累积价格更新放在交易完成之前回头看swap函数的执行顺序先转出token然后计算输入量、做k值校验最后调用_update刷新reserve并更新累积价格。而_update里更新累积价格用的是_reserve0和_reserve1——它们是本次交易开始前的快照。这个顺序非常关键。如果在交易完成后再用新reserve来更新累积价格那么当前交易自己造成的价格变动也会被计入TWAP形成自反馈。Uniswap选择用交易前的储备来累计价格等于把当前这笔交易之前的价格作为这段时间的价格记录这样TWAP更不容易被单一交易影响也保持了价格序列的单调性。有趣的是_update内部还做了一次时间戳判断只有timeElapsed 0时才累加。如果同一区块发生多笔交易只有第一笔会更新累积价格后续交易的时间差为0直接跳过。这个处理避免了同一区块内多笔交易重复记账导致的价格加权失真的问题。5. 我的实操阅读路线版本、工具和容易误读的地方5.1 版本选型V2 core和V3 core的区别Uniswap core仓库有两个主要版本V2和V3。V3引入了集中流动性把LP份额变成了NFTNonfungiblePositionManager核心合约结构也截然不同包含UniswapV3Pool、Tick、Position等复杂概念。我的建议是如果你第一次读Uniswap源码务必从V2开始。V2的代码更短数学更直观所有核心逻辑在一个Pair合约里就能读完。V3虽然功能更强但初始学习成本高很多没有V2的基础直接看V3很容易迷失在tick管理细节里。如果只是想读源码不建议从GitHub主分支拉最新版本更好的是直接安装npm包。npm install uniswap/v2-core装完之后去node_modules/uniswap/v2-core/contracts下看里面有UniswapV2Factory.sol、UniswapV2Pair.sol和libraries目录。这种方式的好处是版本锁定不会因为仓库更新导致代码和你参考的文章对不上。5.2 用Foundry跑测试怎么验证你对源码的理解读合约和读普通代码最大的区别是合约的行为只能在链上验证。我推荐用Foundry写测试来验证源码理解而不是只看代码。Foundry的好处是测试速度快支持主网fork可以直接在真实链上状态上做断言。// SPDX-License-Identifier: MIT pragma solidity ^0.8.13; import forge-std/Test.sol; import {UniswapV2Pair} from uniswap/v2-core/contracts/UniswapV2Pair.sol; contract PairTest is Test { function testKValueCheck() public { // fork主网后直接读取某个真实Pair的状态 address pair 0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852; (uint112 reserve0, uint112 reserve1,) UniswapV2Pair(pair).getReserves(); assertGt(reserve0, 0); assertGt(reserve1, 0); } }我自己验证过的最有价值的几个点包括mint的LP份额计算是否和公式一致、swap的手续费是否真的约等于0.3%、以及_update的累积价格是否随时间差线性变化。把这些写成单元测试后你对源码的理解深度会远超读了一遍。5.3 四个最常见的误读逐个纠正读这套源码时有几个地方我在不同场合见人反复理解错这里专门列出来第一很多人以为Pair合约会主动收取手续费到某个金库地址。实际上手续费不会单独转移它体现在k值校验的*1000 - *3调整中。交易者支付的0.3%手续费最终留在了池子里变成所有LP共同持有的价值而不是进入某个单独账户。只有协议手续费开启后_mintFee会铸造新的LP份额给feeTo。第二很多人以为reserve0和reserve1就是当前余额。这个我在前面强调过但还是要再说一次。getReserves()返回的是上次_update时的快照如果某个代币被直接转账进Pair合约而未经过任何交易reserve和balance会出现短暂不一致。sync函数的存在就是为了处理这种异常状态。第三很多人以为闪电贷在V2里是一个高级玩法需要特殊的合约或接口。实际上flash函数就在Pair合约里而且任何时候都能调用。只要你能在回调函数里把本金加手续费填回去Pair合约根本不关心你借走资金后做了什么。第四很多人以为价格预言机返回的价格就是当前价格。实际上pair.price0CumulativeLast()返回的是累计价格要算出平均价格必须自己记录两个时间点的值再差分。直接拿累计价格当现价用数值会大得离谱这也是新手最容易踩的坑。写在最后读Uniswap V2 core源码给我最大的冲击不是代码本身的复杂度而是它在安全这件事上的偏执。整个Pair合约几乎没有外部依赖计算全部基于余额快照和差值任何传入参数都不可信所有操作都必须满足恒定乘积约束。这种写法牺牲了一部分灵活性却换来了极高的可验证性。当你真正理解了为什么每一处require都写在那个位置之后你会发现DeFi合约的安全性不是靠审计堆出来的而是靠每一行代码都把攻击者能做什么提前想清楚了。如果让我给阅读顺序一句话总结那就是先看懂Factory怎么创建Pair再吃透Pair的mint/burn/swap然后花时间理解flash和累积价格最后再用测试验证你自己的判断。顺着这条线走完再去读V3或者其它AMM项目会轻松很多。