
想搞清楚DEX的资金流转逻辑绕不开Uniswap Core这一层。它不是某个单独的合约而是整套资金池合约的集合Pair、Factory、ERC20、还有三个看着不起眼却决定精度的库。很多朋友一开始学DeFi智能合约都从Uniswap V2的core源码入手因为这套代码结构清晰、文件少、逻辑闭环可以说是Solidity合约教科书级别的工程样本。这篇文章我会按源码学习的主线把Uniswap core的核心合约、关键机制、代码阅读顺序和实际验证方法拆开讲适合刚入门的Solidity开发者也适合已经看过一部分但想理清代码和公式怎么对应的人。我不打算把每行注释都念一遍而是带你抓关键路径core解决什么问题、Pair合约为什么承担了几乎所有核心逻辑、Factory怎么用create2实现确定性部署、库函数里那些移位和乘除到底在防什么。把这几个点啃透剩下的基本都是API调用。1. 先把Core的地图画出来它到底包含哪些合约1.1 core与periphery的边界学Uniswap源码最容易被绕晕的就是仓库分成了两个目录core和periphery。很多人拿到的教程把UniswapV2Router02当成核心合约看结果越看越乱因为Router属于periphery它只是给用户和前端用的导航系统真正的资产托管和交易撮合逻辑全部在core里。core的边界可以用一句话判断凡是直接持有LP资金、直接管理池子储备金、直接铸造销毁LP代币的合约都属于core凡是只负责转账调度、计算路径、拼装调用数据的合约都属于periphery。Router在交易中拿到用户的A代币转给Pair再让Pair把B代币转给用户Pair自己才是实际扣地租的房东。这个边界不是随意划分的它把风险也隔离了。Pair合约极其精简不需要知道用户是谁、不需要知道交易对怎么拼路径只处理给定池子状态下如何安全地完成一次兑换或一次流动性变更。Router即便被攻击或者写得不严谨最坏情况是用户资金调度出错而池子底仓仍然是安全的。这也是我在看代码时觉得值得学的地方职责拆分本身就是一种安全设计。1.2 顶层合约与库的分工打开v2-core仓库的contracts目录看起来文件不多但每个文件的分工需要先建立心理模型。我习惯用一张职责表把它们串起来文件类型核心职责UniswapV2Pair.sol合约每个交易对对应一个Pair实例持有reserve0/reserve1实现mint、burn、swap、sync、skim等核心方法UniswapV2Factory.sol合约统一管理所有Pair的创建维护getPair映射和allPairs列表控制协议手续费开关UniswapV2ERC20.sol合约Pair继承的LP代币实现包含标准的ERC20转账、授权以及permit离线签名Math.sol库提供min函数流动性计算里最常用的最小值判断SafeMath.sol库V2时期做溢出保护的加减乘除封装新版本已内嵌Solidity 0.8的检查UQ112x112.sol库用112位定点数编码reserve支撑区块内价格累积计算从代码量来看Pair是绝对的重头戏。Factory不到100行ERC20不到200行而Pair光是mint、burn、swap这几个函数就藏着整套AMM机制。所以我建议按这样的顺序读代码先看Pair里的核心状态变量再看mint和burn再看swap最后回来看Factory和两个库。因为库的很多函数是被Pair和Router调用的不先理解调用场景很难理解它为什么要做那些位运算。从核心角度说Pair的状态变量很精简reserve0、reserve1是池子里两种代币的数量blockTimestampLast记录上次更新的区块时间戳price0CumulativeLast和price1CumulativeLast用于累积价格预言机kLast用来计算协议手续费。创业公司看融资合约池子看储备这几个变量就是Pair的全部家底。2. Pair合约是整个系统的发动机2.1 恒定乘积公式在代码里怎么落地Uniswap V2最核心的公式是x * y k代码里这个k不变的约束不是靠一个存储变量实现的而是发生在swap函数尾部那几行require里。这个设计常被新手忽略因为大家习惯性地找k的赋值结果发现Pair里只存了kLast而且它不是每次swap都更新。看一段关键代码我标注了它实际在做什么// UniswapV2Pair.swap 的后半段 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 );这里有两个细节需要嚼透。第一为什么是mul(1000).sub(amountIn.mul(3))因为Uniswap V2对每笔swap收取0.3%的手续费这3个基点在数学上等价于把输入金额乘以997/1000后再参与恒定乘积计算。代码把balance先放大1000倍再扣掉输入金额的3倍就是在应用amountInWithFee amountIn * 997这个变形。第二这个require是在消息回调之后、状态更新之前才检查的意味着交易完成后池子里的储备乘积必须不低于交易前的乘积否则交易被回滚。为什么要用不低于而不是等于因为手续费的存在让乘积实际上变大了。没有手续费的恒定乘积是等号有手续费后给LP积累的收益会让k缓慢增长。代码里这个不等式正好把0.3%的手续费和公式的边界统一起来。2.2 mint与burn流动性是怎么铸造和销毁的添加流动性时用户调用RouterRouter把两种代币转进Pair然后调用Pair的mint。Pair的mint函数做的事情是读取当前池子的两种代币余额算出池子实际收到的增量再决定给用户铸造多少LP代币。这里最经典的逻辑是首笔流动性计算// 第一次添加流动性时 uint liquidity Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); _totalSupply liquidity; // 注意MINIMUM_LIQUIDITY 1000被永久锁在池子里公式是sqrt(amount0 * amount1)为什么是几何平均而不是算术平均因为恒定乘积做市商要求流动性价格中性只有当新增的两种代币比例和当前储备比例一致时几何平均和算术平均才等价。首笔流动性没有历史比例可以参考直接给两种代币数量乘积开平方等于按用户实际投入的数量在所有价格区间上均匀分配LP份额。后续添加流动性时就不再开根号了而是按存量储备比例计算新增份额liquidity Math.min( amount0.mul(totalSupply) / _reserve0, amount1.mul(totalSupply) / _reserve1 );这里取min的意义是防止用户单边添加代币薅羊毛。如果只塞A代币不塞B代币按A计算的liquidity会很大但按B计算的值可能为0取min后新增份额被限制在当前比例允许的最大值以内多塞的部分虽然也会进入池子但不会转化为LP份额。我在自己写的AMM教学合约里复刻过这个逻辑实打实体会到这个min是整套公平性的基石。burn的机理正好反过来。用户销毁LP代币时Pair会把合约里的两种代币按份额转出并燃烧相应数量的LP代币。最容易被忽略的是协议手续费在burn里通过_mintFee被结算如果协议手续费开关打开kLast和当前储备乘积的差值里的一部分会被铸造成fees代币再由工厂指定的feeTo地址领取。2.3 swap与flash swap一次交易的全过程理论上swap只需要做一件事把用户给的代币收进池子把池子里的另一种代币转给用户。但真实合约要考虑的事情非常多滑点由Router约束、超额输入要退给谁、储备金怎么更新、累积价格怎么维护、再入攻击怎么防御。先看一对收付的逻辑。Pair会先按amount0Out和amount1Out决定的输出金额执行转账再把这两种转账统统放在同一个_lock修饰符保护下bool private _locked; // 《- 防重入锁 modifier lock() { require(!_locked, UniswapV2: LOCKED); _locked true; _; _locked false; }这段代码是所有swap和mint、burn函数头上的第一道防线。因为Pair做的是低层级的safeTransfer老式ERC20在转账时会触发fallback回调攻击者完全可以在转账过程中再次进入swap函数。_lock本质上就是Solidity版的关着门换钱。swap后半段会检查实际入账金额。借用了我前面那段balance0Adjusted代码核心思路是先拿到池子当前余额减去该转出的金额得到池子实际收到的数量然后再算常数乘积约束。有意思的是V2在设计上并不指定用户应该从哪个方向进入交易用户可以给Pair直接转币然后调用swap甚至不转币直接调用swap再在回调里补币。这种设计不是疏忽而是为flash swap留的口子if (data.length 0) { IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data); }因为先转出代币、后检查余额Pair允许借出资产、在回调结束前归还的操作。既然在回调执行时池子还没有校验余额是否补齐借出的资产就已经被用上了。这个机制就是V2的闪电兑换很多套利者借一大笔资金都不用抵押只要在同一笔交易内还清并支付手续费即可。学到这里你会明白Uniswap的闪电贷款不是V3才有的V2的swap内置的回调机制早就是杠杆套利的基础设施了。交易完成后_update会更新储备金和时间戳同时把价格累积值加上当前价格乘以经过的时间。这两个累积值不直接参与交易而是作为链上预言机数据被外部合约读取后计算时间加权平均价格TWAP。这是core代码里交易之外的隐藏功能但恰恰是它让Uniswap成为一个可靠的链上喂价源。3. Factory与工具库确定性创建与精度控制3.1 create2为什么能算出同一个pair地址Factory的createPair函数每次创建新交易对时都会用到create2操作码配合keccak256(abi.encodePacked(token0, token1))作为盐值。它的核心价值在于任何人只要知道token0和token1的地址就能在本地算出pair的合约地址而不需要真的去链上查询。这个特性带来的直接好处是Router不再依赖Pair的地址簿。Router内部有一个pairFor函数纯粹用CREATE2的地址推导公式在链上重新计算一遍pair地址省掉了一次外部合约调用也更安全。地址推导的完整过程是// 库函数UniswapV2Library.pairFor address pair address(uint160(keccak256(abi.encodePacked( hexff, factory, keccak256(abi.encodePacked(tokenA, tokenB)), keccak256(bytecode) ))));这里hexff是EIP-1014规定的前缀factory是Deployer地址bytecode是Pair合约的创建字节码。之所以要按token0 token1排序是为了保证同一个交易对无论输入顺序如何都会得到同一个pair地址否则A/B和B/A会变成两个池子。我在读Factory源码时有个体会它把getPair[token0][token1]这个可见映射和allPairs数组同时维护里面有明显的保留索引语义。如果你要自己做一个分叉的DEX这种映射记录存在性、数组记录顺序的双层管理模式可以直接照抄。3.2 UQ112x112定点数里的时间与价格UQ112x112.sol是core里最容易被跳过的库但它在预言机机制里扮演关键角色。Solidity不支持浮点数如果直接把reserve相除作为价格会得到截断的整数累积价格这种需要高精度的计算会直接失真。Uniswap的解法是把价格放大2**112倍用定点数表示法保存。库的核心函数只有三个function encode(uint112 y) internal pure returns (uint224 z) { z uint224(y) * Q112; // Q112 2**112 } function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) { z x / uint224(y); // 等价于 x * (2^112 / y) 的定点化 }为什么选112位而不是128位因为Pair里的reserve类型是uint112且两个reserve和一个时间戳打包在同一个uint256槽位里。Solidity存储是32字节一个槽Pair用三个uint112加一个uint32刚好塞满一个槽位读状态只需要一次SLOADgas省了不止一点。这就是工程细节精度选择和存储布局是联合设计的。价格累积值的更新在_update函数里price0CumulativeLast uint128(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed; price1CumulativeLast uint128(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;这段代码用reserve1 / reserve0表示token0的价格乘以经过的区块时间差把价格和时间累加到一起。外部协议只要记录两次调用的累积值用两者之差除以时间间隔就能得到不含操纵痕迹的时间加权平均价格。因为累积值是历史价格的总和攻击者要操纵TWAP需要在多个连续区块里持续保持操纵价格成本远高于瞬时操纵。这正是我强烈建议阅读UQ112x112.sol的原因它只有几行代码却浓缩了一个完整的喂价安全模型。4. 从源码学习到动手验证4.1 环境准备与测试姿势看源码如果只看不跑很多细节会在脑子里打结。我建议直接下载带测试框架的仓库把环境跑通后再逐行验证。实际操作时我用的是一套很简单的组合Foundry加少量测试脚本。Foundry对Solidity源码阅读者极其友好因为它的测试可以直接调用合约函数并断言状态变化不需要写一堆JavaScript桥接逻辑。拿到代码后第一步不是跑测试而是改一个配置把测试网络切换到本地模拟环境使用默认的Anvil节点。Foundry的forge test会自己起一个模拟链环境省去部署的麻烦。接下来建议执行的测试命令是forge test --match-contract UniswapV2PairTest -vvvv加-vvvv会打印完整的事件日志和调用栈适合跟踪每一笔swap内部的状态变化。如果只想验证一个函数用--match-test testSwap这样的路径过滤。我通常会把测试代码里每个断言前加console2.log这样能把reserve、balance、liquidity的变化完整打出来。不要小看这步Pair合约大量逻辑集中在一个函数内用log辅助人肉debug能加速理解十倍。4.2 实操用单测覆盖一条完整swap路径我建议自己写一个最简化的swap测试复制下面这个思路function testSwapPath() public { // 1. 部署Factory创建WETH/DAI交易对 // 2. 添加首笔流动性 // 3. 记录交易前的reserve0和reserve1 // 4. 用Router执行一次精确输入swap // 5. 断言交易后k值不小于交易前k值 // 6. 断言输出金额与getAmountOut公式计算一致 }不要小看这几步它把mint的sqrt(amount0 * amount1)、swap的997/1000常量、以及balance0Adjusted的变形公式全部串起来验证了一遍。我当年写这个测试时踩了个坑首笔流动性如果只添加极小金额MINIMUM_LIQUIDITY锁仓会让实际可用的LP份额趋近于0导致后续测试断言失败。后来我给自己约定测试里首笔流动性至少提供1 ether级别的代币才能留出足够的操作空间。再往深一步你可以尝试构造一个地址伪装攻击直接往Pair合约转账却不调用swap然后观察sync和skim两个函数的区别。sync是把池子当前余额强制设为新的reserveskim是把多余的余额转给指定地址。这个对照实验能让你彻底理解reserve是账面值、balance是真实值这一对概念几乎所有闪电贷风控都建立在这两者的差值上。4.3 V2还是V3源码学习路线怎么选很多朋友纠结到底学V2还是V3。我的建议是如果目标是理解AMM的基础原理先啃V2如果目标是研究当前链上主力协议的工程实现再上V3。V2的core源码在代码量和复杂度上都更适合入门全套核心合约加库文件加起来不到1000行逻辑链路清晰V3的Pool合约加Tick、Oracle、Position库加起来是数倍的工作量虽然抽象水平高但新人直接进去容易迷失在Gas优化和位运算的细节里。从技术演进角度看V2里学到的恒定乘积、储备金机制、手续费模型在V3里都有对应但升级的版本。V3把流动性按Tick区间拆分、引入集中流动性、把价格空间离散化成格子本质上是在V2的全价格区间做市基础上做了一个更精细的切片。我把常见学习路径对比如下维度V2 CoreV3 Core核心文件Pair.sol Factory.solUniswapV3Pool.sol Tick.sol Oracle.sol价格模型全区间恒定乘积离散Tick的集中流动性LP代币同质化ERC20非同质化Position NFT预言机累积价格 TWAP累积价格 多观察点TWAP闪电贷回调式flash swap内置flash函数还款方式更规范入门难度较低较高中如果你时间有限我更推荐V2为主、V3为辅的读法把V2的Pair合约彻底吃透再去看V3的Pool文档和TickMath库你的理解会顺畅得多。V3的getTickAtSqrtRatio这类函数本质上就是把价格与tick之间的数学映射做一个二分查找没有V2的储备概念打底很难懂得它为什么要这么设计。5. 我踩过的坑与排查心得5.1 版本和分支坑Uniswap官方仓库在v2-core下维护着多个分支和tag有些教程直接贴master分支代码结果里面已经是Solidity 0.6甚至0.8的版本和老教程的0.5.16版本函数签名差得不少。比如旧版SafeMath库在0.8之后不再是必须品很多教程却还让你找一个不存在的SafeMath.sol文件。我建议直接锁定官方v2-core的master分支对应的tag或者干脆查看contracts/libraries目录下的文件列表来确认版本。另外要特别留意编译器的匹配问题。V2 core早期的合约用的是Solidity 0.5.16如果拿新版本编译器强行编译会报出大量private函数可见性之类的兼容性错误。我在本机复现时用了Foundry的solc版本管理功能在foundry.toml里手动指定solc_version为对应版本整个编译才顺畅通过。5.2 精度与Gas坑精度相关的坑主要集中在两点。第一不要直接用普通除法对reserve做价格计算必须用UQ112x112或类似定点数方案否则结果会被截断到个位数TWAP合约里时间加权累加出来的价格可能完全失真。第二MINIMUM_LIQUIDITY锁仓的1000个最小份额单位在首笔流动性极小的情况下会占据极大比例导致LP份额几乎为零。真实项目里首笔流动性通常由协议方或做市商一次性注入足够大的额度这也是为什么很多新池子会显示一个较高的初始锁仓值。Gas坑也很隐蔽。阅读Pair源码会发现很多_token0、_token1这样的局部变量缓存这是为了减少SLOAD。如果你在自建合约里沿用逻辑但没做存储缓存每笔swap的gas成本会明显上升。学源码时关注这些冗余变量不是在学死代码而是在学EVM的存储成本模型。我后来写生产级合约时凡是会多次读取的storage变量都会做一次本地缓存这个习惯就是从Pair里学来的。5.3 常见问题速查表现象原因解决办法swap后回滚UniswapV2: K实际入账金额不足或手续费扣除逻辑有误检查余额变化是否按997/1000入池mint得到0份额首笔流动性小于MINIMUM_LIQUIDITY提高首笔注入金额调用pairFor计算的地址与实际Pair不符使用的pair bytecode版本不一致确认Pair合约的creationCode与目标链一致链上事件里出现两次swap回调函数内重入但未命中锁检查lock修饰符和回调逻辑是否覆盖完整路径参考合约里报SafeMath not found版本不匹配或依赖缺失锁定旧版solc或改用0.8内建检查排查顺序我建议固定为先确认版本与编译状态再用balanceOf对比实际余额与reserve差异最后看事件日志里的时间和价格累积值。大多数所谓合约逻辑错误最后都会落在储备值没有更新或输入输出方向搞反这两个点上。在我自己啃完这套源码后最大的体会是Uniswap core被反复推崇不是因为它用了多高深的花哨技术而是把AMM最朴素的经济学原理用Solidity的工程约束精准落地。公式里一个3就对应了997/1000的手续费一个2**112就对上了EVM存储槽的位宽一个create2就让链上地址可被离线推导。这种每一处代码都服务于一个明确目的的质感值得反复读。学习时不需要背代码只需要不断追问为什么这里这样写源码会一点点把答案交给你。