本文探讨了 DeFi 交易中滑点问题,重点关注了开发者和审计人员应注意的常见实现错误,包括缺少滑点参数、无过期时间、错误的滑点计算、精度不匹配、铸币过程中的滑点问题、中间操作的滑点参数使用不当、链上滑点计算易被操纵以及硬编码滑点可能冻结用户资金等,并提供了详细的案例和代码示例。
滑点是指市场参与者提交 DeFi 交换交易时的价格与该交易执行时的实际价格之间的差额。这种差异通常可以忽略不计,但在高波动时期以及流动性低的代币中可能非常显著。滑点可能导致用户收到的代币多于(通常)少于如果交易是瞬间执行的情况。
DeFi 平台应允许用户指定一个滑点参数 "minTokensOut",即从交换中收到的最小输出代币数量,这样,如果交换无法返回用户指定的最小输出代币数量,交换将回滚。DeFi 系统中存在几种常见的实现错误,开发者和审计人员应该注意。
DeFi 平台必须允许用户指定滑点参数:他们希望从交换中返回的最小代币数量。审计人员应始终注意将滑点设置为 0 的交换:
IUniswapRouterV2(SUSHI_ROUTER).swapExactTokensForTokens(
toSwap,
0, // @audit 最小返回 0 个代币;没有滑点 => 用户资金损失
path,
address(this),
now
);
这段代码告诉交换,用户将接受从交换中获得的最小数量为 0 的输出代币,从而使用户容易受到通过 MEV 机器人三明治攻击 造成的灾难性资金损失。如果用户没有指定值,平台还应提供一个合理的默认值,但用户指定的滑点参数必须始终覆盖平台默认值。更多例子:[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]
像自动做市商(AMM)这样的高级协议可以允许用户指定一个截止时间参数,该参数强制执行交易必须执行的时间限制。如果没有截止时间参数,交易可能会停留在内存池中,并在稍后的时间执行,这可能会导致用户获得更差的价格。
协议不应将截止时间设置为 block.timestamp,因为验证者可以持有交易,并且最终放入的区块将是 block.timestamp
,因此这不提供任何保护。
协议应允许与 AMM 交互的用户设置过期截止时间;没有过期截止时间可能会为任何发起交换的用户造成潜在的严重资金损失漏洞,尤其是在也没有滑点参数的情况下。查看 Sherlock 的 BlueBerry Update 1 竞赛中的这一高严重性发现:
// 2. 将奖励代币交换为债务代币
uint256 rewards = _doCutRewardsFee(CRV);
_ensureApprove(CRV, address(swapRouter), rewards);
swapRouter.swapExactTokensForTokens(
rewards,
0, // @audit 没有滑点,可以接收 0 个输出代币
swapPath,
address(this),
type(uint256).max // @audit 没有截止时间,交易可以
// 在更不利的时间执行
);
这里 "minTokensOut" 硬编码为 0,因此交换可能会返回 0 个输出代币,并且截止时间参数硬编码为 utint256 的最大值,因此交易可以被持有并在稍后和对用户更不利的时间执行。没有滑点和没有截止时间的这种组合使用户面临损失所有输入代币的潜在风险! 更多例子:[ 1, 2, 3, 4, 5, 6]
滑点参数应该类似于 "minTokensOut" - 用户将接受交换的最小代币数量。任何其他情况都是一个值得注意的危险信号,因为它可能构成一个不正确的滑点参数。考虑来自 OpenZeppelin 的 Origin Dollar 审计的这段简化的代码:
function withdraw(address _recipient, address _asset, uint256 _amount
) external onlyVault nonReentrant {
// ...
// 计算我们需要提取多少池代币
(uint256 contractPTokens, , uint256 totalPTokens) = _getTotalPTokens();
uint256 poolCoinIndex = _getPoolCoinIndex(_asset);
// 计算如果我们提取所有平台代币,我们将获得的最大资产数量
ICurvePool curvePool = ICurvePool(platformAddress);
uint256 maxAmount = curvePool.calc_withdraw_one_coin(
totalPTokens,
int128(poolCoinIndex)
);
// 计算我们需要提取多少平台代币才能获得资产数量
uint256 withdrawPTokens = totalPTokens.mul(_amount).div(maxAmount);
// 计算最小提款金额
uint256 assetDecimals = Helpers.getDecimals(_asset);
// 3crv 为 1e18,减去滑点百分比并缩放到资产
// 小数位
// @audit 不使用用户提供的 _amount,而是基于 LP 代币计算一个无意义的值
uint256 minWithdrawAmount = withdrawPTokens
.mulTruncate(uint256(1e18).sub(maxSlippage))
.scaleBy(int8(assetDecimals - 18));
curvePool.remove_liquidity_one_coin(
withdrawPTokens,
int128(poolCoinIndex),
minWithdrawAmount
);
// ...
}
以及审计后修复的版本:
curvePool.remove_liquidity_one_coin(
withdrawPTokens,
int128(poolCoinIndex),
_amount
);
每个智能合约审计员都应密切关注协议对用户指定的滑点参数所做的任何异常修改。更多例子:[ 1, 2, 3, 4, 5, 6, 7, 8, 9]
一些平台允许用户从一组具有各种不同精度值的输出token中赎回或提取。这些平台必须确保滑点参数 "minTokensOut" 缩放以匹配所选输出 token 的精度,否则滑点参数可能无效并导致精度损失错误。考虑这段来自 Sherlock 的 RageTrade 竞赛的代码:
function _convertToToken(address token, address receiver) internal returns (uint256 amountOut) {
// 此值应该是通过调用 withdraw/redeem 到 junior vault 收到的 glp
uint256 outputGlp = fsGlp.balanceOf(address(this));
// 使用 glp 的最低价格因为在 glp 中给出
uint256 glpPrice = _getGlpPrice(false);
// 使用 token 的最高价格因为从 gmx 中取出 token
uint256 tokenPrice = gmxVault.getMaxPrice(token);
// @audit 总是返回 6 位小数,不适用于许多 token
// 在估计的输出金额之上应用滑点阈值
uint256 minTokenOut = outputGlp.mulDiv(glpPrice * (MAX_BPS - slippageThreshold), tokenPrice * MAX_BPS);
// @audit 需要调整滑点精度以匹配输出
// token 小数点位,如下所示:
// minTokenOut = minTokenOut * 10 ** (token.decimals() - 6);
// 如果至少没有收到 minTokenOut 将会被还原
amountOut = rewardRouter.unstakeAndRedeemGlp(address(token), outputGlp, minTokenOut, receiver);
}
"minTokenOut" 总是返回 6 位小数,但用户可以从一组具有各种不同精度的token中指定一个输出token,因此必须缩放滑点以匹配输出token的精度。更多例子:[ 1]
许多 DeFi 协议允许用户转移外部 token 以铸造协议的本地 token - 这在功能上与用户交换外部 token 以换取协议的本地 token 相同。由于这被打包并呈现为“铸造”,因此可能会省略滑点参数,从而使用户面临无限滑点风险!考虑来自 Code4rena 的 Vader 审计的这段代码:
function mintSynth(IERC20 foreignAsset, uint256 nativeDeposit,
address from, address to) returns (uint256 amountSynth) {
// @audit 转移外部 token
nativeAsset.safeTransferFrom(from, address(this), nativeDeposit);
ISynth synth = synthFactory.synths(foreignAsset);
if (synth == ISynth(_ZERO_ADDRESS))
synth = synthFactory.createSynth(
IERC20Extended(address(foreignAsset))
);
// @audit 前端运行者可以操纵这些储备来影响
// 铸造的 token 数量
(uint112 reserveNative, uint112 reserveForeign, ) = getReserves(
foreignAsset
); // 节省 gas
// @audit 基于上述储备铸造协议的 token
// 这实际上是一个交换,不允许用户
// 指定滑点参数,使用户面临无限滑点风险
amountSynth = VaderMath.calculateSwap(
nativeDeposit,
reserveNative,
reserveForeign
);
_update(
foreignAsset,
reserveNative + nativeDeposit,
reserveForeign,
reserveNative,
reserveForeign
);
synth.mint(to, amountSynth);
}
当基于池储备或其他可以在实时操纵的链上数据实现铸造功能时,开发人员应提供并且审核员应验证用户是否可以指定滑点参数,因为此类铸造实际上是另一种名称的交换!更多例子:[ 1, 3]
由于 DeFi 的可组合性,交换可以在将最终金额的token返回给用户之前执行多个操作。如果 "minTokensOut" 参数用于中间操作 但不用于检查最终金额,则这可能导致用户的资金损失漏洞,因为他们可能收到的token少于指定的数量。考虑来自 Sherlock 的 Olympus Update 竞赛中的这段简化的代码:
function withdraw(
uint256 lpAmount_,
uint256[] calldata minTokenAmounts_, // @audit 滑点参数
bool claim_
) external override onlyWhileActive onlyOwner nonReentrant returns (uint256, uint256) {
// ...
// @audit minTokenAmounts_ 在此处强制执行,但这只是
// 中间操作,而不是用户收到的最终金额
// 退出 Balancer 池
_exitBalancerPool(lpAmount_, minTokenAmounts_);
// 计算收到的 OHM 和 wstETH 数量
uint256 ohmAmountOut = ohm.balanceOf(address(this)) - ohmBefore;
uint256 wstethAmountOut = wsteth.balanceOf(address(this)) - wstethBefore;
// 计算预言机预期的 wstETH 收到数量
// getTknOhmPrice 返回基于预言机价格的每 1 OHM 的 wstETH 数量
uint256 wstethOhmPrice = manager.getTknOhmPrice();
uint256 expectedWstethAmountOut = (ohmAmountOut * wstethOhmPrice) / _OHM_DECIMALS;
// @audit 这是最终操作,但 minTokenAmounts_ 不再
// 强制执行,因此返回给用户的金额可能少于
// 指定的 minTokenAmounts_,导致用户的资金损失
//
// 获取相对于金库预言机价格的任何套利,并将剩余部分返回给所有者
uint256 wstethToReturn = wstethAmountOut > expectedWstethAmountOut
? expectedWstethAmountOut
: wstethAmountOut;
if (wstethAmountOut > wstethToReturn)
wsteth.safeTransfer(TRSRY(), wstethAmountOut - wstethToReturn);
// ...
}
在这里,用户指定的滑点参数 "minTokenAmounts_" 仅对中间操作 _exitBalancerPool() 强制执行,之后金库可以通过撇去 balancer 和预言机预期回报金额之间的差额来进一步减少token的输出量。开发人员和审核员应测试并验证用户指定的 "minTokensOut" 始终在交换的最后一步强制执行,然后再将token返回给用户。更多例子:[ 1, 2]
检查来自 Sherlock 的 Derby 竞赛的这段代码:
function swapTokensMulti(
SwapInOut memory _swap,
IController.UniswapParams memory _uniswap,
bool _rewardSwap
) public returns (uint256) {
IERC20(_swap.tokenIn).safeIncreaseAllowance(_uniswap.router, _swap.amount);
// @audit 链上滑点计算可能被操纵,
// Quoter.quoteExactInput() 本身会执行一个交换!
// https://github.com/Uniswap/v3-periphery/blob/main/contracts/lens/QuoterV2.sol#L138-L146
// amountOutMinimum 必须由用户指定,链下计算
uint256 amountOutMinimum = IQuoter(_uniswap.quoter).quoteExactInput(
abi.encodePacked(_swap.tokenIn, _uniswap.poolFee, WETH, _uniswap.poolFee, _swap.tokenOut),
_swap.amount
);
uint256 balanceBefore = IERC20(_swap.tokenOut).balanceOf(address(this));
if (_rewardSwap && balanceBefore > amountOutMinimum) return amountOutMinimum;
ISwapRouter.ExactInputParams memory params = ISwapRouter.ExactInputParams({
path: abi.encodePacked(
_swap.tokenIn,
_uniswap.poolFee,
WETH,
_uniswap.poolFee,
_swap.tokenOut
),
recipient: address(this),
deadline: block.timestamp,
amountIn: _swap.amount,
amountOutMinimum: amountOutMinimum
});
ISwapRouter(_uniswap.router).exactInput(params);
uint256 balanceAfter = IERC20(_swap.tokenOut).balanceOf(address(this));
return balanceAfter - balanceBefore;
}
此代码尝试通过使用 Quoter.quoteExactInput() 来执行链上滑点计算,而 Quoter.quoteExactInput() 本身会执行一个交换,因此容易受到通过 三明治攻击 进行的操纵。开发人员应确保并且审核员必须验证允许用户指定他们自己链下计算的滑点参数。更多例子:[ 1, 2, 3]
设置滑点的想法是保护用户免受因高波动性而损失比他们想要的更少的token,并阻止他们被 MEV 机器人利用。那么为什么项目不只是硬编码低滑点来保护用户?因为硬编码的滑点会在市场动荡期间冻结用户资金。检查来自 Code4rena 的 Sturdy 竞赛的这段代码:
function withdrawCollateral(
address _asset,
uint256 _amount,
address _to
) external virtual {
// 在从借贷池中提取之前,获取 stAsset 地址和提取金额
// 例如:在 Lido 金库中,它将返回 stETH 地址和相同的金额
(address _stAsset, uint256 _stAssetAmount) = _getWithdrawalAmount(_asset, _amount);
// 从 lendingPool 中提取,它会将用户的 aToken 转换为 stAsset
uint256 _amountToWithdraw = ILendingPool(_addressesProvider.getLendingPool()).withdrawFrom(
_stAsset,
_stAssetAmount,
msg.sender,
address(this)
);
// 从金库中提取,它会将 stAsset 转换为资产并发送给用户
// 例如:在 Lido 金库中,它会将 ETH 或 stETH 返回给用户
uint256 withdrawAmount = _withdrawFromYieldPool(_asset, _amountToWithdraw, _to);
if (_amount == type(uint256).max) {
uint256 decimal = IERC20Detailed(_asset).decimals();
_amount = _amountToWithdraw.mul(this.pricePerShare()).div(10**decimal);
}
// @audit 硬编码的滑点会导致所有提款在高波动时期还原,
// 冻结用户资金。用户应该有权通过设置自己的滑点在高波动时期提款。
require(withdrawAmount >= _amount.percentMul(99_00), Errors.VT_WITHDRAW_AMOUNT_MISMATCH);
emit WithdrawCollateral(_asset, _to, _amount);
}
此代码在提款时设置了非常小的滑点。虽然这可以保护用户免受因滑点造成的资金损失,但在高波动时期,当滑点不可避免时,它也会导致所有提款还原,从而冻结用户资金。如果项目使用默认滑点,用户应该始终能够使用自己的滑点覆盖它,以确保他们即使在高波动时期也能进行交易。更多例子:[ 1, 2, 3, 4]
在 UniswapV3 中,流动性可以分布在多个费用层级中。如果启动 uni v3 交换的函数硬编码费用层级参数,这可能会产生几个负面影响:
硬编码的费用层级可能不存在于该token对,导致交换失败,即使流动性存在于不同的费用层级
硬编码的费用层级可能提供低于另一个费用层级的流动性,从而导致用户更大的滑点
允许用户执行 uni v3 交换的函数应允许用户传入费用层级参数。
需要零滑点的函数很可能会还原,从而对用户造成持久的拒绝服务。期望零滑点是不现实的,这就是为什么开发人员必须允许用户指定滑点参数。
- 原文链接: dacian.me/defi-slippage-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!