UniswapV2Library 工具库详解

逐函数拆解 getAmountOut/getAmountIn/getAmountsOut 等八个无状态辅助函数及其背后的恒定乘积数学

8 分钟阅读
UniswapV2Library 工具库详解

UniswapV2Library 工具库详解

目录


一、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)

  • 完整实现:sortTokenspairForgetReservesquotegetAmountOutgetAmountIngetAmountsOutgetAmountsIn
  • 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)

  1. test_GetAmountOut_MatchesFormula:用本文第十节的数字断言 ≈ 90.66(精确整数);
  2. test_GetAmountIn_RoundsUp:断言 getAmountIn 结果带 +1 向上取整,且用它做 swap 一定能拿到目标输出(不会差 1 wei);
  3. test_PairFor_MatchesCreate2:pairFor 算出的地址 == factory 实际部署地址;
  4. test_MultiHop_AtoCgetAmountsOut(100e18, [A,B,C]) 返回长度 3 的数组,且 amounts[2] 等于”先 A→B 再 B→C”手工两步计算的结果;
  5. test_MultiHopExecutionMatchesQuote:沿路径真实执行两次 swap,最终到账的 C 数量 == getAmountsOut 的 amounts[2];
  6. test_Quote_IsLinear:quote(100, 1000, 2000) == 200(验证线性、无滑点)。

Sepolia 部署与验证步骤

  1. 部署三种代币、Factory、两个池子、PathQuoter、QuoterFacade;
  2. 在 Etherscan 上调用 getAmountsOut(100e18, [A,B,C]),记下 amounts[2];
  3. approve 100 A 给第一个池,按 amounts 逐池调用 swap(A→B→C),核对最终 C 余额;
  4. getAmountIn 反向问”想要 50 C 要付多少 A”,按结果执行验证。

进阶挑战(可选)

  • 写一个链下脚本(ethers.js/viem)遍历所有可能路径,比较 A→C 直连池(如果建)vs A→B→C 两跳,输出最优路径——亲手实现”链下路由”;
  • 在 README 中分析:为什么 getAmountsOut 不在链上做路径搜索?如果强行做,Gas 复杂度是多少?

💬 评论