前言
诶嘿,刚审计了个Swap项目,项目的Swap函数手续费收了两次…
为了把问题描述的清晰一些,我仔细地追了一下swap中手续费的计算方法。
于是机缘巧合的发现了Uniswap
K值检查的奇妙,和我想象中的检查方式有些差别。
本文做一个技术点的记录和分享。
前置知识
AMM的K值守恒
在AMM中进行交易,遵循K值守恒原则,Swap前后的reverse乘积不变。
详细兑换原理,可查看 uniswap解析与举例
问题发现
在描述问题过程之前,笔者奉上UniswapV2Pair:swap
的代码以便查阅:
// this low-level function should be called from a contract which performs important safety checks
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
uint balance0;
uint balance1;
{ // scope for _token{0,1}, avoids stack too deep errors
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
balance0 = IERC20Uniswap(_token0).balanceOf(address(this));
balance1 = IERC20Uniswap(_token1).balanceOf(address(this));
}
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
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');
}
_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
根据AMM的K值守恒要求,当Swap完成时,uniswap底层应检查一下K值是否与交易前相等。
代码大概是 balance0 * balance1 == K
这样子,至少应该是个等于号
。
但是当我们跟进到UniswapV2Pair:swap
函数时,发现检查K值守恒的用的是>=
号,对应代码是
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
嘿嘿,神奇啊。这和预期的K值守恒完全不是一个意思呀。
带着充满好奇的满心欢喜,我们瞅瞅上面代码是咋写的。
等号左边儿咋害能比K值大呢?
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
瞅这两行,balance0Adjusted
和balance1Adjusted
是pair新余额的校准值。
校准的部分是刨去手续费的amountIn
。
看到这里我就更懵了: 这一Adjust后,balance更小了。
这乘积肯定比K值小啊,咋害能大呢。
为啥不是小于K值
我们仔细看一下amountIn
的来源,
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
可见amountIn
是 真实balance - (reverse - amountOut)
所以amountIn
的含义是 真实的输入量
,即包含0.3%
手续费的那个100%AmountIn
。
这么一看我就悟了。
哦,我的老伙计。
用户把钱打进来的地方可是在swap函数调用之前呀。
如UniswapV2Router02.sol:swapExactTokensForTokens
:
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external virtual override ensure(deadline) returns (uint[] memory amounts) {
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
TransferHelper.safeTransferFrom(
path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
);
_swap(amounts, path, to);
}
- 打钱的地方是
TransferHelper.safeTransferFrom
- 这时候
balance
发生变化,增加了100%AmountIn
- 但是下面
_swap
兑换的时候,用的AmountOut
是getAmountOut
return的那个99.7%AmountOut
。 - 剩余的
0.3%feeValue
被留在了pair里面,造成了输入币reverse
的增大。
后面pair.swap
时,通过校准
将这0.3%
异常抹除。
这时再比较K值,就不是小于而是相等啦。
为啥还能大于K值
ok 不是小于而是相等
的问题解决了。
剩下就是 Uniswap
为啥还留了个大于号
。
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
要搞清楚这个问题,我们需要回到Uniswap第一次开始检查K值守恒
的时间点:
commit:580787051ed66b75210c14269a3a48b4bcdee620
在这个版本中,人类历史上首次提出了"UniswapV2: K"
的要求
} else {
...
require(amountIn.mul(reserve0 - amountOut).mul(997) >= amountOut.mul(reserve1).mul(1000), "UniswapV2: K");
...
}
哭辽,原来swap方法
从一开始就没想过等价交换
或者什么K值守恒
它只是要求 扣除手续费后的AmountIn
要大于等于 取走的AmountOut
换句话说:
你往pair
里面多扔钱我不拦着;想多拿走?绝不可能!(╬ ̄皿 ̄)=○
总结
这么一追,我悟了。
>=
设计本身就是一个合约的利己行为。
Swap的K值守恒也只是限制user
别换走额外的钱,
但你要是无偿提供点流动性,本合约绝不拦你~ ╰( ̄▽ ̄)╭
碎碎念
使用>=
而不是==
,放宽了K值守恒的限制,
这意味着攻击者有了更多的操作空间。
至于能怎么利用这个小trick
,以后再想吧…