第三章 AMM机制审计:恒定乘积公式、滑点计算、无常损失、流动性池安全
好,咱们进入正题。AMM,也就是自动做市商,是DEX的核心。你想想看,没有订单簿,全靠一个数学公式来定价,这本身就够刺激的。审计AMM,说白了就是审计这个公式在极端情况下会不会崩。
我个人习惯,拿到一个AMM合约,先不看代码,先看它的核心公式。是Uniswap V2的恒定乘积?还是V3的集中流动性?或者是Curve的稳定币兑换?公式不同,攻击面完全不同。
3.1 恒定乘积公式:x * y = k
这个公式太经典了。x是代币A的储备量,y是代币B的储备量,k是常数。交易的本质,就是改变x和y,但保持k不变(或者准确说,k在交易后略微增加,因为收了手续费)。
审计这个公式,我重点关注三点:
- 精度问题:Solidity没有浮点数,所有计算都得用整数。如果除法的精度丢失了,攻击者就能利用这个漏洞套利。我在项目中遇到过,一个DeFi项目因为除法的舍入方向搞反了,被机器人薅走了几万美金。
- 溢出检查:x * y 可能会溢出。虽然Solidity 0.8+默认带溢出检查,但很多项目还在用0.6、0.7。如果用了unchecked,那就要手动检查。
- k值的更新:交易后,k值应该增加(因为手续费)。但有些项目写错了,k值反而减少了。这会导致流动性提供者亏钱。
核心审计点:检查 getAmountOut 和 getAmountIn 这两个函数。它们分别计算“用多少输入换多少输出”和“要得到多少输出需要多少输入”。公式必须严格遵循 x * y = k。
// 典型的 getAmountOut 实现(Uniswap V2 风格)
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
// 输入不能为0
require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
// 储备不能为0
require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
uint amountInWithFee = amountIn * 997; // 0.3% 手续费
uint numerator = amountInWithFee * reserveOut;
uint denominator = reserveIn * 1000 + amountInWithFee;
amountOut = numerator / denominator;
}
嗯,这里要注意。上面的代码里,amountInWithFee = amountIn * 997,这个997是硬编码的。如果项目方改了手续费比例,比如改成0.05%,那这里就要改成 amountIn * 9995 / 10000。我见过一个项目,改了手续费但忘了改这个常数,结果交易对账永远对不上。
3.2 滑点计算:别让用户被“插针”
滑点,就是你下单时的预期价格和实际成交价格之间的差异。在AMM里,滑点是必然存在的,因为你的交易会改变池子的比例。
审计滑点,我主要看两点:
- 前端滑点保护:用户在前端设置一个滑点容忍度(比如0.5%)。如果实际滑点超过这个值,交易应该回滚。很多攻击就是利用高滑点来“抢跑”用户的。
- 合约内的滑点检查:有些项目把滑点检查写在了合约里,而不是依赖前端。这更安全,因为前端可以被篡改。但要注意,合约里的滑点检查参数必须是用户传入的,不能是硬编码的。
我的经验:我曾经审计过一个项目,它的滑点检查写的是 require(amountOut >= minAmountOut),但 minAmountOut 是合约自己算出来的,而不是用户传入的。这等于没检查。攻击者只要把交易拆成多笔,每笔的滑点都在“允许”范围内,就能把用户的钱掏空。
3.3 无常损失:流动性提供者的“隐形杀手”
无常损失,说白了就是你把币放进池子,比单纯持有这两个币,可能亏得更多。为什么会这样?因为AMM的定价机制决定了,当外部市场价格波动时,池子里的币会被套利者搬走。
审计无常损失,其实不是审计代码,而是审计项目方有没有对用户进行充分的风险披露。但代码层面,我们可以检查:
- 手续费分配:无常损失靠手续费来弥补。如果手续费分配机制有问题,LP(流动性提供者)可能永远回不了本。
- 流动性迁移:有些项目允许LP随时撤走流动性。如果池子深度不够,LP撤走时可能引发“死亡螺旋”。
注意:无常损失不是代码漏洞,而是数学特性。但审计时,如果发现项目方在文档里故意淡化无常损失的风险,或者用复杂的代币经济学来掩盖,那就要警惕了。我见过一个项目,宣传“零无常损失”,结果它的稳定币池子因为脱锚,LP亏了90%。
3.4 流动性池安全:别让池子被“抽干”
流动性池是DEX的命根子。池子被攻击,整个DEX就废了。审计流动性池,我重点关注:
- 添加/移除流动性:这两个函数必须对称。添加时按比例增加储备,移除时按比例减少储备。如果不对称,攻击者就能通过反复添加/移除来套利。
- 闪电贷攻击:闪电贷可以借出巨额资金,瞬间操纵池子价格。审计时要检查,合约是否对价格操纵有防护。比如,使用时间加权平均价格(TWAP)而不是瞬时价格。
- 重入攻击:虽然AMM通常不会回调外部合约,但如果用了ERC-777之类的代币,就要小心了。ERC-777在转账时会回调接收方,攻击者可以利用这个重入。
// 不安全的添加流动性示例
function addLiquidity(uint amountA, uint amountB) external {
// 没有检查比例!
_safeTransferFrom(tokenA, msg.sender, address(this), amountA);
_safeTransferFrom(tokenB, msg.sender, address(this), amountB);
reserveA += amountA;
reserveB += amountB;
// 铸造LP代币...
}
上面的代码,攻击者可以只转大量的tokenA,不转tokenB,然后马上移除流动性,把池子掏空。正确的做法是,添加流动性时必须按当前储备比例来。
3.5 知识体系总览
下面这张图,是我自己总结的AMM审计知识体系。你审计的时候,可以对照着看,别漏了哪个环节。
这张图把AMM审计分成了四个大块。你审计的时候,可以按这个框架来,一个一个过。别跳步,跳步容易漏。
一个小技巧:我审计AMM时,会先写一个测试脚本,模拟各种极端情况:大额交易、小额交易、闪电贷、重入。如果测试能跑通,代码基本没问题。如果测试跑不通,那恭喜你,找到漏洞了。
好了,AMM机制审计就聊这么多。记住,核心是公式,关键是边界。别被花里胡哨的包装迷惑了,回到数学本身,漏洞往往就在那里。