Uniswap V2 源码学习第七篇:恒定乘积公式,getAmountOut 是怎么推出来的?

tomenengr 发布于 2026-05-11 阅读 224

Library中的公式推导

上一篇我们拆了 Pair.swap()

swap() 本身并不负责报价,它只负责最终检查:

require(
    balance0Adjusted.mul(balance1Adjusted)
        >= uint(_reserve0).mul(_reserve1).mul(1000**2),
    'UniswapV2: K'
);

也就是说,Pair 的工作是:

你想这么换?可以。
但交易结束后,扣除手续费后的 K 不能变小。

那报价是谁算的?

主要是这个库:

UniswapV2Library.sol

里面最经典的函数就是:

function getAmountOut(
    uint amountIn,
    uint reserveIn,
    uint reserveOut
) internal pure returns (uint amountOut)

这一篇我们就从数学公式推到源码,看清楚:

给定输入 amountIn
给定池子储备 reserveIn / reserveOut
最多可以换出多少 amountOut?

1. 先看 getAmountOut 源码

源码很短:

function getAmountOut(
    uint amountIn,
    uint reserveIn,
    uint reserveOut
) internal pure returns (uint amountOut) {
    require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
    require(
        reserveIn > 0 && reserveOut > 0,
        'UniswapV2Library: INSUFFICIENT_LIQUIDITY'
    );

    uint amountInWithFee = amountIn.mul(997);
    uint numerator = amountInWithFee.mul(reserveOut);
    uint denominator = reserveIn.mul(1000).add(amountInWithFee);

    amountOut = numerator / denominator;
}

如果只看代码,它像是在背公式。

但这个公式其实就是从:

x * y = k

一步一步推出来的。

其中:

reserveIn  = 输入 token 在池子里的储备
reserveOut = 输出 token 在池子里的储备
amountIn   = 用户输入数量
amountOut  = 用户最多能拿走的输出数量

2. 不考虑手续费时,公式怎么推?

先暂时忽略手续费。

假设池子里有两种资产:

x = reserveIn
y = reserveOut

恒定乘积模型要求:

x * y = k

用户输入 amountIn 后,输入侧储备变成:

x + amountIn

用户拿走 amountOut 后,输出侧储备变成:

y - amountOut

为了保证 K 不变小,需要满足:

(x + amountIn) * (y - amountOut) >= x * y

如果我们算“最多能拿多少”,就取等号:

(x + amountIn) * (y - amountOut) = x * y

开始解 amountOut

y - amountOut = x * y / (x + amountIn)

所以:

amountOut = y - x * y / (x + amountIn)

通分一下:

amountOut = ((x + amountIn) * y - x * y) / (x + amountIn)

展开:

amountOut = (x*y + amountIn*y - x*y) / (x + amountIn)

消掉 x*y

amountOut = amountIn * y / (x + amountIn)

换回源码变量:

amountOut = amountIn * reserveOut / (reserveIn + amountIn)

这就是不考虑手续费的版本。


3. 加上 0.3% 手续费

Uniswap V2 默认 swap fee 是 0.3%。

也就是说,如果用户输入:

amountIn

真正参与兑换计算的有效输入不是全部,而是:

amountIn * 997 / 1000

剩下的:

amountIn * 3 / 1000

作为手续费留在池子里。

所以我们把公式里的 amountIn 替换成有效输入:

amountInWithFee = amountIn * 997 / 1000

理论公式变成:

amountOut =
amountInWithFee * reserveOut
/
(reserveIn + amountInWithFee)

如果 Solidity 支持小数,这么写就行。

但 Solidity 没有浮点数,所以源码会避免直接除以 1000。


4. 为什么源码里 amountInWithFee = amountIn * 997?

源码是:

uint amountInWithFee = amountIn.mul(997);

它没有写:

uint amountInWithFee = amountIn.mul(997).div(1000);

为什么?

因为如果提前除以 1000,会产生更大的整数截断误差。

所以源码把分母里的 1000 留到最后统一处理。

我们从公式看:

amountOut =
(amountIn * 997 / 1000) * reserveOut
/
(reserveIn + amountIn * 997 / 1000)

为了消掉分母里的 /1000,上下同时乘以 1000

amountOut =
amountIn * 997 * reserveOut
/
(reserveIn * 1000 + amountIn * 997)

这就变成了源码:

uint amountInWithFee = amountIn.mul(997);
uint numerator = amountInWithFee.mul(reserveOut);
uint denominator = reserveIn.mul(1000).add(amountInWithFee);
amountOut = numerator / denominator;

也就是:

分子 = amountIn * 997 * reserveOut
分母 = reserveIn * 1000 + amountIn * 997

这就是 getAmountOut() 的完整来源。


5. 用数字跑一遍

假设池子是:

reserveIn  = 10,000 DAI
reserveOut = 10,000 USDC

用户输入:

amountIn = 1,000 DAI

源码计算:

amountInWithFee = 1000 * 997 = 997000

numerator = 997000 * 10000
          = 9,970,000,000

denominator = 10000 * 1000 + 997000
            = 10,000,000 + 997,000
            = 10,997,000

amountOut = 9,970,000,000 / 10,997,000
          = 906

所以用户大约能拿到:

906 USDC

注意是整数除法,结果向下取整。

也就是不会给用户多算。

这个 906 就是上一篇 swap() 例子里能通过 K 检查的输出数量。

如果拿 907,K 检查就会失败。


6. getAmountOut 和 swap 的关系

getAmountOut() 是报价函数。

swap() 是执行和验证函数。

它们的关系大概是:

Router / Library:
    根据 reserve 和 amountIn 算出 amountOut

Pair.swap:
    允许转出 amountOut
    最后检查扣除手续费后的 K 是否不变小

也就是说,getAmountOut() 算出来的值,应该刚好能让 swap() 的 K 检查通过。

流程是:

1. Router 调用 getAmountOut,算出用户最多能拿多少
2. Router 把输入 token 转进 Pair
3. Router 调用 Pair.swap,指定 amountOut
4. Pair 根据最终 balance 反推 amountIn
5. Pair 做 K 检查

这也是为什么 Pair 不需要直接调用 getAmountOut()

Pair 不关心报价过程,只关心最终状态是否合法。


7. 为什么 amountOut 会小于线性比例?

很多初学者会觉得:

池子 10000 DAI / 10000 USDC
我输入 1000 DAI
为什么不能换 1000 USDC?

因为 AMM 不是固定价格订单簿。

你输入 1000 DAI 后,池子里的 DAI 变多,USDC 变少,价格会沿着曲线滑动。

恒定乘积曲线是:

x * y = k

你不是以一个固定价格完成整笔交易,而是沿着曲线一点点买。

所以输出小于 1000。

这里面有两部分损耗:

1. 价格冲击 price impact
2. 交易手续费 0.3%

在刚才例子里,输入 1000 DAI,输出 906 USDC。

差距不是只有 0.3%,因为 1000 DAI 相对于 10000 DAI 的池子来说,已经是 10% 的大单。

池子越小、交易越大,价格冲击越明显。


8. 价格冲击从哪里来?

不考虑手续费时,公式是:

amountOut = amountIn * reserveOut / (reserveIn + amountIn)

如果池子很大,比如:

reserveIn  = 1,000,000
reserveOut = 1,000,000
amountIn   = 1,000

那:

amountOut ≈ 999

接近 1:1。

但如果池子很小:

reserveIn  = 10,000
reserveOut = 10,000
amountIn   = 1,000

那不考虑手续费:

amountOut ≈ 909

已经明显低于 1000。

这就是价格冲击。

本质上是:

你的交易改变了池子的 reserve 比例。

AMM 价格不是外部喂进去的,而是池子资产比例自己决定的。


9. getAmountIn:反过来算要输入多少

有了 getAmountOut(),自然还有反向函数:

function getAmountIn(
    uint amountOut,
    uint reserveIn,
    uint reserveOut
) internal pure returns (uint amountIn)

它回答的问题是:

如果我想精确拿到 amountOut,至少需要输入多少 amountIn?

源码是:

function getAmountIn(
    uint amountOut,
    uint reserveIn,
    uint reserveOut
) internal pure returns (uint amountIn) {
    require(amountOut > 0, 'UniswapV2Library: INSUFFICIENT_OUTPUT_AMOUNT');
    require(
        reserveIn > 0 && reserveOut > 0,
        'UniswapV2Library: INSUFFICIENT_LIQUIDITY'
    );

    uint numerator = reserveIn.mul(amountOut).mul(1000);
    uint denominator = reserveOut.sub(amountOut).mul(997);

    amountIn = (numerator / denominator).add(1);
}

这里最值得注意的是最后:

.add(1)

为什么要加 1?

因为整数除法会向下取整。

但这里算的是“至少需要输入多少”。

如果向下取整,可能会少付一点点,导致 Pair 的 K 检查失败。

所以源码加 1,保证输入足够。


10. getAmountIn 公式怎么推?

还是从恒定乘积开始。

设:

x = reserveIn
y = reserveOut

用户想拿走:

amountOut

那么输出侧变成:

y - amountOut

输入侧需要增加有效输入:

amountInWithFee = amountIn * 997 / 1000

K 等式:

(x + amountInWithFee) * (y - amountOut) = x * y

amountInWithFee

x + amountInWithFee = x * y / (y - amountOut)

所以:

amountInWithFee = x * y / (y - amountOut) - x

通分:

amountInWithFee =
[x*y - x*(y - amountOut)] / (y - amountOut)

展开:

amountInWithFee =
[x*y - x*y + x*amountOut] / (y - amountOut)

消掉:

amountInWithFee =
x * amountOut / (y - amountOut)

换回变量:

amountIn * 997 / 1000 =
reserveIn * amountOut / (reserveOut - amountOut)

amountIn

amountIn =
reserveIn * amountOut * 1000
/
((reserveOut - amountOut) * 997)

这就是源码:

uint numerator = reserveIn.mul(amountOut).mul(1000);
uint denominator = reserveOut.sub(amountOut).mul(997);
amountIn = (numerator / denominator).add(1);

这里的 add(1) 是整数取整保护。数学推导给出的是边界值,源码要保证实际转入数量不会因为向下取整而少 1 个最小单位。


11. getAmountOut 和 getAmountIn 的使用场景

这两个函数对应 Router 里两类 swap。

exact input:输入固定,输出尽可能多

例如:

swapExactTokensForTokens(
    amountIn,
    amountOutMin,
    path,
    to,
    deadline
)

用户说:

我就花 1000 DAI,帮我尽可能多换 USDC。
但最终输出不能低于 amountOutMin。

这类场景用:

getAmountsOut()

它内部会逐跳调用 getAmountOut()

exact output:输出固定,输入尽可能少

例如:

swapTokensForExactTokens(
    amountOut,
    amountInMax,
    path,
    to,
    deadline
)

用户说:

我就要 1000 USDC。
你最多可以花我 amountInMax 个 DAI。

这类场景用:

getAmountsIn()

它内部会反向逐跳调用 getAmountIn()


12. quote:添加流动性时的比例函数

UniswapV2Library 里还有一个很简单但很重要的函数:

function quote(
    uint amountA,
    uint reserveA,
    uint reserveB
) internal pure returns (uint amountB) {
    require(amountA > 0, 'UniswapV2Library: INSUFFICIENT_AMOUNT');
    require(
        reserveA > 0 && reserveB > 0,
        'UniswapV2Library: INSUFFICIENT_LIQUIDITY'
    );

    amountB = amountA.mul(reserveB) / reserveA;
}

它回答的问题是:

如果我想按当前池子比例加入 amountA,那么我应该配多少 amountB?

公式很简单:

amountB = amountA * reserveB / reserveA

比如当前池子:

reserveA = 10,000 DAI
reserveB = 20,000 USDC

比例是:

1 DAI : 2 USDC

如果用户加入:

amountA = 100 DAI

那么应该配:

amountB = 100 * 20000 / 10000 = 200 USDC

这个函数主要用于 Router._addLiquidity()

它不是 swap 报价,而是添加流动性时报比例。


13. getAmountsOut:多跳正向计算

单跳用 getAmountOut()

多跳用:

function getAmountsOut(
    address factory,
    uint amountIn,
    address[] memory path
) internal view returns (uint[] memory amounts)

源码大概是:

function getAmountsOut(
    address factory,
    uint amountIn,
    address[] memory path
) internal view returns (uint[] memory amounts) {
    require(path.length >= 2, 'UniswapV2Library: 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
        );
    }
}

假设路径是:

DAI → WETH → USDC

也就是:

path = [DAI, WETH, USDC]

那么:

amounts[0] = 输入 DAI 数量
amounts[1] = 第一跳能得到多少 WETH
amounts[2] = 第二跳能得到多少 USDC

Router 后面执行 _swap() 时,就按这个 amounts 数组逐跳调用 Pair。


14. getAmountsIn:多跳反向计算

对应反向函数:

function getAmountsIn(
    address factory,
    uint amountOut,
    address[] memory path
) internal view returns (uint[] memory amounts)

源码大概是:

function getAmountsIn(
    address factory,
    uint amountOut,
    address[] memory path
) internal view returns (uint[] memory amounts) {
    require(path.length >= 2, 'UniswapV2Library: INVALID_PATH');

    amounts = new uint[](path.length);
    amounts[amounts.length - 1] = amountOut;

    for (uint i = path.length - 1; i > 0; i--) {
        (uint reserveIn, uint reserveOut) =
            getReserves(factory, path[i - 1], path[i]);

        amounts[i - 1] = getAmountIn(
            amounts[i],
            reserveIn,
            reserveOut
        );
    }
}

如果路径还是:

DAI → WETH → USDC

用户想最终拿到:

1000 USDC

那么它会从后往前算:

想拿 1000 USDC,需要多少 WETH?
想得到这些 WETH,需要多少 DAI?

最后得到:

amounts[0] = 需要输入多少 DAI
amounts[1] = 中间需要多少 WETH
amounts[2] = 目标输出 1000 USDC

这就是 exact output swap 的报价逻辑。


15. getReserves:为什么要按传入顺序返回?

Library.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);
}

Pair 内部永远是:

token0
token1

但外部调用时可能传:

tokenA, tokenB

顺序不一定等于 token0/token1。

所以 getReserves() 会做一层转换:

你传入 tokenA/tokenB
我返回 reserveA/reserveB
保证顺序和你传入的一致

这样 getAmountOut(amountIn, reserveIn, reserveOut) 才不会把方向搞反。

这是 Router 能正确支持任意 path 顺序的关键。


16. sortTokens 和 pairFor:Library 的底层工具

Library 里还有两个基础函数。

sortTokens

function sortTokens(address tokenA, address tokenB)
    internal
    pure
    returns (address token0, address token1)
{
    require(tokenA != tokenB, 'UniswapV2Library: IDENTICAL_ADDRESSES');

    (token0, token1) = tokenA < tokenB
        ? (tokenA, tokenB)
        : (tokenB, tokenA);

    require(token0 != address(0), 'UniswapV2Library: ZERO_ADDRESS');
}

它和 Factory 里的排序逻辑一致。

作用是:

把任意 tokenA/tokenB 归一化成 token0/token1

pairFor

function pairFor(
    address factory,
    address tokenA,
    address tokenB
) internal pure returns (address pair)

它根据 CREATE2 公式直接计算 Pair 地址。

简化理解:

pair = hash(factory, token0, token1, INIT_CODE_PAIR_HASH)

这使得 Router 不需要每次都调用 Factory 的 getPair()

它可以直接算出 Pair 地址。

不过这个函数有一个经典坑:

如果你 fork Uniswap V2 并修改了 Pair 合约,
一定要更新 INIT_CODE_PAIR_HASH。

否则 pairFor() 算出来的地址会和 Factory 实际创建的 Pair 地址不一致。


17. Library 的设计特点

UniswapV2Library 有一个很清晰的定位:

它不保存状态。
它不转账。
它不调用 swap。
它只负责计算和查询。

它负责:

1. token 排序
2. Pair 地址计算
3. reserve 顺序转换
4. 添加流动性比例 quote
5. 单跳 swap 报价
6. 多跳 swap 报价

真正的状态变化都不在 Library。

状态变化发生在:

Pair.mint()
Pair.burn()
Pair.swap()
Router 的转账调用

这种设计让 Library 很安全,也很容易被复用。


18. getAmountOut 的源码直觉

现在我们回头再看:

uint amountInWithFee = amountIn.mul(997);
uint numerator = amountInWithFee.mul(reserveOut);
uint denominator = reserveIn.mul(1000).add(amountInWithFee);
amountOut = numerator / denominator;

它其实就是:

amountOut =
amountIn * 997 * reserveOut
/
(reserveIn * 1000 + amountIn * 997)

背后的含义是:

输入 amountIn
扣掉 0.3% 手续费
剩余有效输入沿 x*y=k 曲线换出 reserveOut

这里的 9971000 对应手续费。

这里的 reserveInreserveOut 对应交易方向。

这里的整数除法向下取整,保护池子,不给用户多算输出。


19. getAmountIn 的源码直觉

再看:

uint numerator = reserveIn.mul(amountOut).mul(1000);
uint denominator = reserveOut.sub(amountOut).mul(997);
amountIn = (numerator / denominator).add(1);

它其实是:

amountIn =
reserveIn * amountOut * 1000
/
((reserveOut - amountOut) * 997)

最后 +1 是为了避免整数除法向下取整导致输入不够。

它的语义是:

如果我想拿走 amountOut,
我至少要输入这么多 tokenIn,
而且要多补 1 个最小单位保证 K 检查过关。

这一点和 getAmountOut() 的向下取整是一对:

getAmountOut:输出向下取整,保护池子
getAmountIn:输入向上取整,保护池子

非常工整。


20. 一个完整多跳例子

假设路径:

DAI → WETH → USDC

用户输入:

amountIn = 10,000 DAI

Router 会调用:

amounts = UniswapV2Library.getAmountsOut(
    factory,
    amountIn,
    path
);

假设算出来:

amounts[0] = 10,000 DAI
amounts[1] = 5 WETH
amounts[2] = 14,800 USDC

然后 Router 执行:

1. 把 10,000 DAI 转入 DAI/WETH Pair
2. 调用 DAI/WETH Pair.swap,让它输出 5 WETH
3. 这 5 WETH 直接发到 WETH/USDC Pair
4. 调用 WETH/USDC Pair.swap,让它输出 14,800 USDC
5. 最终 USDC 发给用户

每一跳都用各自的 reserve 计算。

每一跳也都会独立通过自己的 K 检查。

如果中间任意一跳不满足 K,整笔交易 revert。


21. 报价函数不是价格预言机

这里要特别提醒一下:

getAmountOut() 不是安全的价格预言机。

它只是基于当前池子 reserve 的即时计算。

如果有人在当前区块操纵 reserve,比如用闪电贷临时推动价格,那么 getAmountOut() 得到的报价也会被影响。

所以:

Router 用它做即时交易报价没问题。
但其他协议不能随便拿它当安全价格源。

Uniswap V2 真正提供给外部做预言机基础的是:

price0CumulativeLast
price1CumulativeLast

也就是 TWAP 累计价格机制。

后面讲 _update() 和 Oracle 时会展开。

一句话:

getAmountOut 是交易报价。
TWAP 才是预言机思路。

这俩别混。


22. 这一篇的核心脉络

我们把这一篇压缩一下。

getAmountOut() 来自:

(x + 有效输入) * (y - 输出) = x * y

手续费让有效输入变成:

amountIn * 997 / 1000

为了避免浮点数,源码写成:

amountInWithFee = amountIn * 997
amountOut =
    amountInWithFee * reserveOut
    /
    (reserveIn * 1000 + amountInWithFee)

getAmountIn() 是反向求解:

amountIn =
    reserveIn * amountOut * 1000
    /
    ((reserveOut - amountOut) * 997)
    + 1

getAmountsOut()getAmountsIn() 则是把单跳公式扩展到多跳 path。


小结

这一篇我们把 Uniswap V2 的交易数学和源码连起来了。

你现在应该能回答:

1. getAmountOut 是干什么的?
   给定输入金额和池子储备,计算最多能输出多少。

2. 公式从哪里来?
   从恒定乘积公式 x*y=k 推导而来。

3. 997 和 1000 是什么?
   0.3% 手续费,997/1000 是有效输入比例。

4. 为什么不先除以 1000?
   为了减少整数截断误差。

5. getAmountIn 为什么最后 add(1)?
   因为要向上取整,确保输入足够通过 K 检查。

6. quote 是干什么的?
   添加流动性时按当前 reserve 比例计算另一边 token 数量。

7. getAmountsOut 和 getAmountsIn 如何支持多跳?
   一个正向逐跳算输出,一个反向逐跳算输入。

8. getAmountOut 能不能当预言机?
   不应该。它只是当前 reserve 的即时报价,容易被短期操纵。

到这里,交易这条主线已经比较完整了:

Library 负责算报价
Router 负责转账和路径
Pair.swap 负责执行和 K 检查

相关文章

0 条评论