UniswapV2Library 工具库详解
目录
- 一、UniswapV2Library 是什么
- 二、数学基础:从恒定乘积到兑换公式
- 三、sortTokens:代币排序
- 四、pairFor:离线计算池子地址
- 五、getReserves:读取储备并对齐顺序
- 六、quote:按比例报价(注意闪电贷风险)
- 七、getAmountOut:给定输入求输出(含手续费推导)
- 八、getAmountIn:给定输出求输入
- 九、getAmountsOut / getAmountsIn:多跳路径
- 十、数值例子串讲
- 十一、总结
- 十二、动手练习项目:DEX 路径报价器 PathQuoter
一、UniswapV2Library 是什么
UniswapV2Library 是一个纯函数(pure/view)工具库,提供 8 个不改变状态的辅助函数,被 Router 和其他集成方大量使用。它本身不持有资金、不存储状态,只做计算和地址推导。八个函数:
| 函数 | 作用 |
|---|---|
sortTokens | 把两个代币地址排序,得到规范的 token0 < token1 |
pairFor | 用 CREATE2 离线算出池子地址 |
getReserves | 读取池子储备,并按调用方传入的顺序对齐 |
quote | 按当前比例做线性报价(无滑点、无手续费) |
getAmountOut | 单跳:给定输入算输出(含 0.3% 费) |
getAmountIn | 单跳:给定目标输出算所需输入 |
getAmountsOut | 多跳:沿路径逐跳算输出 |
getAmountsIn | 多跳:沿路径逐跳倒推输入 |
二、数学基础:从恒定乘积到兑换公式
所有计算都源自恒定乘积。交易前后乘积相等:
x·y = (x + Δx)(y − Δy)
解出 Δy(无手续费):
Δy = (y · Δx) / (x + Δx)
加入 0.3% 手续费——输入只有 99.7% 有效,用整数化的 997/1000 表示:
amountInWithFee = amountIn × 997
amountOut = (reserveOut × amountInWithFee) / (reserveIn × 1000 + amountInWithFee)
这就是 getAmountOut 的最终形式,下面第七节会逐步推导。
三、sortTokens:代币排序
function sortTokens(address tokenA, address tokenB)
internal pure returns (address token0, address token1)
{
require(tokenA != tokenB, 'IDENTICAL_ADDRESSES');
(token0, token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), 'ZERO_ADDRESS');
}
作用:保证 (A,B) 和 (B,A) 得到同一个规范顺序,这样同一对代币永远对应唯一的池子地址,也保证 pairFor 计算的 salt 一致。
四、pairFor:离线计算池子地址
function pairFor(address factory, address tokenA, address tokenB)
internal pure returns (address pair)
{
(address token0, address token1) = sortTokens(tokenA, tokenB);
pair = address(uint160(uint(keccak256(abi.encodePacked(
hex'ff',
factory,
keccak256(abi.encodePacked(token0, token1)), // salt
hex'96e8ac42...init code hash...' // Pair initcode 哈希常量
)))));
}
pure 函数,零存储读取就能算出池子地址(原理见架构篇的 CREATE2 讲解)。Router 靠它在不查 mapping 的情况下定位每个池子,省 Gas。
五、getReserves:读取储备并对齐顺序
function getReserves(address factory, address tokenA, address tokenB)
internal view returns (uint reserveA, uint reserveB)
{
(address token0,) = sortTokens(tokenA, tokenB);
(uint reserve0, uint reserve1,) = IUniswapV2Pair(pairFor(factory, tokenA, tokenB)).getReserves();
(reserveA, reserveB) = tokenA == token0 ? (reserve0, reserve1) : (reserve1, reserve0);
}
池子内部按 token0/token1 存储储备,但调用方可能按 (tokenA=B, tokenB=A) 的顺序问。这个函数把储备对齐到调用方的顺序,并丢掉时间戳字段,返回干净的两个数。
六、quote:按比例报价(注意闪电贷风险)
function quote(uint amountA, uint reserveA, uint reserveB)
internal pure returns (uint amountB)
{
require(amountA > 0, 'INSUFFICIENT_AMOUNT');
require(reserveA > 0 && reserveB > 0, 'INSUFFICIENT_LIQUIDITY');
amountB = amountA * reserveB / reserveA; // 线性比例
}
quote 是线性的:按当前池子比例换算,不含滑点也不含手续费。它主要用于 addLiquidity——计算”提供 amountA 个 A,应该配多少 B 才能匹配当前比例”。
⚠️ 安全警告:quote 反映的是瞬时现货价,可被闪电贷操纵。绝不能用它做预言机定价(DamnVulnerableDeFi 的 Puppet V2 关卡正是利用了误用 quote 的协议)。要安全定价请用 TWAP。
七、getAmountOut:给定输入求输出(含手续费推导)
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut)
internal pure returns (uint amountOut)
{
require(amountIn > 0, 'INSUFFICIENT_INPUT_AMOUNT');
require(reserveIn > 0 && reserveOut > 0, 'INSUFFICIENT_LIQUIDITY');
uint amountInWithFee = amountIn * 997;
uint numerator = amountInWithFee * reserveOut;
uint denominator = reserveIn * 1000 + amountInWithFee;
amountOut = numerator / denominator;
}
逐步推导:从 (reserveIn + 0.997·amountIn)(reserveOut − amountOut) = reserveIn·reserveOut 出发,解出:
amountOut = (0.997·amountIn · reserveOut) / (reserveIn + 0.997·amountIn)
分子分母同乘 1000,把 0.997 变成整数 997:
amountOut = (997·amountIn · reserveOut) / (1000·reserveIn + 997·amountIn)
完全对应代码。注意整数运算先乘后除,避免精度损失。
八、getAmountIn:给定输出求输入
反向问题:我想精确拿到 amountOut,至少要付多少 amountIn?
function getAmountIn(uint amountOut, uint reserveIn, uint reserveOut)
internal pure returns (uint amountIn)
{
require(amountOut > 0, 'INSUFFICIENT_OUTPUT_AMOUNT');
require(reserveIn > 0 && reserveOut > 0, 'INSUFFICIENT_LIQUIDITY');
uint numerator = reserveIn * amountOut * 1000;
uint denominator = (reserveOut - amountOut) * 997;
amountIn = numerator / denominator + 1; // +1 向上取整,确保足额
}
由 getAmountOut 公式反解得到。末尾 +1 是关键:整数除法向下取整会导致输入略少、拿不到目标输出,加 1 保证输入足够(这部分多付的极小金额归 LP)。
九、getAmountsOut / getAmountsIn:多跳路径
单个池子只能换一对代币。要把 A 换成 C,可能需要走 A→B→C 的路径(穿过两个池子)。
function getAmountsOut(address factory, uint amountIn, address[] memory path)
internal view returns (uint[] memory amounts)
{
require(path.length >= 2, 'INVALID_PATH');
amounts = new uint[](path.length);
amounts[0] = amountIn;
for (uint i; i < path.length - 1; i++) {
(uint reserveIn, uint reserveOut) = getReserves(factory, path[i], path[i + 1]);
amounts[i + 1] = getAmountOut(amounts[i], reserveIn, reserveOut);
}
}
要点:
- 它返回的是每一跳的金额数组,不只是最终输出——Router 后续会用
amounts[i]逐池调用 swap; getAmountsIn反方向:从期望的最终输出倒推,循环从后往前;- 路径 path 是外部传入的——最优路由的搜索发生在链下(前端/聚合器),合约只负责按给定路径计算。链上找最优路径会非常费 Gas。
十、数值例子串讲
池子:reserveIn = 1000,reserveOut = 1000,输入 amountIn = 100。
- amountInWithFee = 100 × 997 = 99,700
- numerator = 99,700 × 1000 = 99,700,000
- denominator = 1000 × 1000 + 99,700 = 1,099,700
- amountOut = 99,700,000 / 1,099,700 ≈ 90.66
反验 getAmountIn(想拿 90 个输出):
- numerator = 1000 × 90 × 1000 = 90,000,000
- denominator = (1000 − 90) × 997 = 907,270
- amountIn = 90,000,000 / 907,270 + 1 = 99 + 1 = 100(与上面一致,误差来自取整)
十一、总结
- Library 是无状态计算 + 地址推导工具,被 Router 重度依赖;
- getAmountOut/In 是含 0.3% 费的恒定乘积正反解,整数化为 997/1000;
- getAmountsOut/In 沿外部传入的 path 逐跳计算,路由搜索在链下;
- quote 是线性现货报价,仅用于加流动性,严禁用作预言机。
十二、动手练习项目:DEX 路径报价器 PathQuoter
项目目标
实现一个完整的 PathQuoter 库 + 包装合约,把本文 8 个函数全部亲手写一遍,并搭一个 3 个池子的小型 DEX(A、B、C 三种代币,建 A-B 和 B-C 两个池),在 Sepolia 上验证多跳报价与实际成交一致。
合约要求
1. PathQuoter.sol(library)
- 完整实现:
sortTokens、pairFor、getReserves、quote、getAmountOut、getAmountIn、getAmountsOut、getAmountsIn; pairFor的 init code hash 用你自己 Pair 合约的keccak256(type(MyPair).creationCode),先写个脚本打印出来再硬编码进库(体会真实 fork 部署时为何要重算这个常量)。
2. QuoterFacade.sol(把 library 暴露成可在 Etherscan 调用的 external 函数)
- 因为 library 的 internal 函数无法直接从 Etherscan 调,包一层
external view转发,方便手动验证。
3. 复用前几篇的 Pair + Factory,部署 TokenA/B/C,建 A-B、B-C 两个池并各注入流动性(比例故意不同,比如 A-B 是 1000:2000,B-C 是 2000:1000)。
测试要求(Foundry)
test_GetAmountOut_MatchesFormula:用本文第十节的数字断言 ≈ 90.66(精确整数);test_GetAmountIn_RoundsUp:断言 getAmountIn 结果带 +1 向上取整,且用它做 swap 一定能拿到目标输出(不会差 1 wei);test_PairFor_MatchesCreate2:pairFor 算出的地址 == factory 实际部署地址;test_MultiHop_AtoC:getAmountsOut(100e18, [A,B,C])返回长度 3 的数组,且 amounts[2] 等于”先 A→B 再 B→C”手工两步计算的结果;test_MultiHopExecutionMatchesQuote:沿路径真实执行两次 swap,最终到账的 C 数量 == getAmountsOut 的 amounts[2];test_Quote_IsLinear:quote(100, 1000, 2000) == 200(验证线性、无滑点)。
Sepolia 部署与验证步骤
- 部署三种代币、Factory、两个池子、PathQuoter、QuoterFacade;
- 在 Etherscan 上调用
getAmountsOut(100e18, [A,B,C]),记下 amounts[2]; - approve 100 A 给第一个池,按 amounts 逐池调用 swap(A→B→C),核对最终 C 余额;
- 调
getAmountIn反向问”想要 50 C 要付多少 A”,按结果执行验证。
进阶挑战(可选)
- 写一个链下脚本(ethers.js/viem)遍历所有可能路径,比较 A→C 直连池(如果建)vs A→B→C 两跳,输出最优路径——亲手实现”链下路由”;
- 在 README 中分析:为什么 getAmountsOut 不在链上做路径搜索?如果强行做,Gas 复杂度是多少?