Uniswap V2 源码学习第七篇:恒定乘积公式,getAmountOut 是怎么推出来的?
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
这里的 997 和 1000 对应手续费。
这里的 reserveIn 和 reserveOut 对应交易方向。
这里的整数除法向下取整,保护池子,不给用户多算输出。
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 检查