本文深入探讨Solidity编程中数值运算可能导致的精度损失问题,包括除法后乘法、向下取整至零、未进行精度缩放、过度精度缩放、精度缩放不匹配、向下转型溢出以及协议价值因四舍五入而泄露等,并提供了避免这些问题的实用建议和代码示例。
Solidity 中的数值运算可能导致精度损失,即计算、保存和返回的金额不正确,并且通常低于应有的金额。这些错误可能会损害去中心化金融平台的用户,有时也可能被攻击者利用来从这些平台提取资金。
在 Solidity 中,除法可能导致向下取整的错误,因此为了最大限度地减少任何取整误差,我们总是希望先执行乘法后执行除法。考虑一下来自 Numeon 的 code4rena 竞赛的这个简化的代码:
import "openzeppelin-contracts/utils/math/Math.sol";
// source: https://code4rena.com/reports/2023-01-numoen#h-01-precision-loss-in-the-invariant-function-can-lead-to-loss-of-funds
error InvariantError();
function errorInvariant(uint amount0,
uint amount1,
uint liquidity,
uint token0Scale,
uint token1Scale) public view returns (uint256) {
if (liquidity == 0) {
require (amount0 == 0 && amount1 == 0);
return 0;
}
// @audit: division can cause rounding so always want to do it last. Doing
// multiplicationa after division as occurs here can cause precision loss
// @审计:除法会导致取整,所以总是希望最后做。像这里一样在除法之后做
// 乘法会导致精度损失
uint256 scale0 = Math.mulDiv(amount0, 1e18, liquidity) * token0Scale;
uint256 scale1 = Math.mulDiv(amount1, 1e18, liquidity) * token1Scale;
console.log("Loss of precision: multiplication after division");
// 精度损失:先除后乘
console.log("scale0 : ", scale0);
console.log("scale1 : ", scale1);
uint upperBound = 5 * 1e18;
if (scale1 > 2 * upperBound) revert InvariantError();
uint256 a = scale0 * 1e18;
uint256 b = scale1 * upperBound;
uint256 c = (scale1 * scale1) / 4;
uint256 d = upperBound * upperBound;
console.log("a : ", a);
console.log("b : ", b);
console.log("c : ", c);
console.log("d : ", d);
return a + b - c - d;
在这里,scale0 和 scale1 可能会由于除法后进行乘法而导致显著的精度损失,这可能会影响后续的计算。为了防止这种情况,始终在除法之前执行乘法:
function correctInvariant(uint amount0,
uint amount1,
uint liquidity,
uint token0Scale,
uint token1Scale) public view returns (uint256) {
if (liquidity == 0) {
require (amount0 == 0 && amount1 == 0);
return 0;
}
// @audit: changed to perform division after multiplication
// @审计:更改为在乘法后执行除法
uint256 scale0 = Math.mulDiv(amount0 * token0Scale, 1e18, liquidity);
uint256 scale1 = Math.mulDiv(amount1 * token1Scale, 1e18, liquidity);
console.log("Prevent precision loss: multiplication before division");
// 防止精度损失:先乘后除
console.log("scale0 : ", scale0);
console.log("scale1 : ", scale1);
uint upperBound = 5 * 1e18;
if (scale1 > 2 * upperBound) revert InvariantError();
uint256 a = scale0 * 1e18;
uint256 b = scale1 * upperBound;
uint256 c = (scale1 * scale1) / 4;
uint256 d = upperBound * upperBound;
console.log("a : ", a);
console.log("b : ", b);
console.log("c : ", c);
console.log("d : ", d);
return a + b - c - d;
}
这段代码可以封装在一个简单的测试工具中,以准确显示精度损失是如何发生的:
function testInvariantDivBeforeMult() public {
uint token0Amount = 1.5*10**6;
uint token1Amount = 2 * (5 * 10**24 - 10**21);
uint liquidity = 10**24;
uint token0Precision = 10**12;
uint token1Precision = 1;
uint result = vulnContract.errorInvariant(
token0Amount, token1Amount, liquidity, token0Precision, token1Precision);
assertEq(0, result);
result = vulnContract.correctInvariant(
token0Amount, token1Amount, liquidity, token0Precision, token1Precision);
assertEq(500000000000000000000000000000, result);
}
测试输出显示 "scale0" 中存在显著的精度损失,并传递到 "a" 中:
Loss of precision: multiplication after division
scale0 : 1000000000000
scale1 : 9998000000000000000
a : 1000000000000000000000000000000
b : 49990000000000000000000000000000000000
c : 24990001000000000000000000000000000000
d : 25000000000000000000000000000000000000
Prevent precision loss: multiplication before division
scale0 : 1500000000000
scale1 : 9998000000000000000
a : 1500000000000000000000000000000
b : 49990000000000000000000000000000000000
c : 24990001000000000000000000000000000000
d : 25000000000000000000000000000000000000
在 Numeon 的审计案例中,攻击者可以利用 invariant() 函数中的这种精度损失来错误地满足不变量检查,从而从合约中提取资金。
有时,先除后乘的错误可以通过等式中的函数调用对审计员隐藏。审计员可以使用的一种技术是手动展开函数调用,以揭示任何隐藏的先除后乘。考虑一下来自Yield VR 审计的这个简化的发现:
iRate = baseVbr + utilRate.wmul(slope1).wdiv(optimalUsageRate)
// expand out wmul & wdiv to see what is actually going on
// 展开 wmul 和 wdiv 以查看实际情况
// iRate = baseVbr + utilRate * (slope1 / 1e18) * (1e18 / optimalUsageRate)
//
// now can see Division Before Multiplication:
// 现在可以看到先除后乘:
// (slope1 / 1e18) 然后乘以 (1e18 / optimalUsageRate),
// 导致精度损失。
//
// To fix, always perform Multiplication Before Division:
// 为了解决这个问题,始终先乘后除:
// iRate = baseVbr + utilRate * slope1 / optimalUsageRate;
这里 wmul() 和 wdiv() 隐藏了存在先除后乘的事实,但一旦展开函数调用,它就会变得可见。始终记住:先乘后除!
更多示例:[ 1, 2, 3, 4, 5, 6, 7, 8, 9]
正如我们之前看到的,solidity 中的除法可能导致向下取整,为了最大限度地减少精度损失,我们总是需要在除法之前进行乘法运算。然而,即使我们这样做,仍然可能发生一些精度损失,尤其是在处理小数字时,如果处理不当,向下取整为零可能会成为重大错误的来源。考虑一下来自 Cooler 的 sherlock 竞赛的这个简化的贷款偿还代码:
// source: https://github.com/sherlock-audit/2023-01-cooler-judging/issues/263
function errorRepay(uint repaid) external {
console.log("PrecisionLoss.errorRepay()");
// @audit if repaid small enough, decollateralized will round down to 0,
// allowing loan to be repaid without changing collateral
// @审计 如果 repaid 足够小,decollateralized 将向下取整为 0,
// 允许在不改变抵押品的情况下偿还贷款
uint decollateralized = loanCollateral * repaid / loanAmount;
loanAmount -= repaid;
loanCollateral -= decollateralized;
console.log("decollateralized : ", decollateralized);
console.log("loanAmount : ", loanAmount);
console.log("loanCollateral : ", loanCollateral);
}
这里我们有一笔已用一些抵押品获得的贷款,以及一个用于偿还部分或全部贷款的函数。当发生偿还时,贷款金额和贷款抵押品都必须按比例减少。然而,如果以小增量偿还,“decollateralized” 将向下取整为零,因此贷款金额将减少,而抵押品不会减少。为了解决这个问题,如果发生这种情况,该函数应该回退:
function correctRepay(uint repaid) external {
uint decollateralized = loanCollateral * repaid / loanAmount;
// @audit don't allow loan repayment without deducting from
// collateral in the case of rounding to zero from small repayment
// @审计 在由于小额偿还而四舍五入为零的情况下,不允许在不从
// 抵押品中扣除的情况下偿还贷款
if( decollateralized == 0 ) { revert("Round down to zero"); }
loanAmount -= repaid;
loanCollateral -= decollateralized;
}
使用一个简单的测试工具来验证它:
function testDivRoundToZero() public {
// borrow 10 USDC using 1 USDC as collateral
// 借用 10 USDC,使用 1 USDC 作为抵押品
uint loanAmount = 10 * USDC_PRECISION;
uint loanCollateral = 1 * USDC_PRECISION;
vulnContract.setLoanAmount(loanAmount);
vulnContract.setLoanCollateral(loanCollateral);
// repay very small amount
// 偿还非常少的金额
uint repayAmount = 0.000009 * 10**6;
vulnContract.errorRepay(repayAmount);
// loan amount reduced but collateral stayed the same
// 贷款金额减少,但抵押品保持不变
assertEq(loanAmount-repayAmount, vulnContract.loanAmount());
assertEq(loanCollateral, vulnContract.loanCollateral());
vm.expectRevert("Round down to zero");
vulnContract.correctRepay(repayAmount);
}
测试运行的输出显示了发生的四舍五入为零的情况:
PrecisionLoss.errorRepay()
decollateralized : 0
loanAmount : 9999991
loanCollateral : 1000000
为了防止这个错误,总是要考虑你的计算是否可能向下取整为零,尤其是在使用小数字时,如果是,你的代码是否应该回退。更多示例:[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
考虑一个交易池,该交易池将主要代币与次要代币进行交易;这些代币可能各自具有不同的精度。例如,DAI 有 18 个小数位,而 USDC 只有 6 个小数位;DAI/USDC 或 USDC/DAI 是常见的稳定币池,允许市场参与者在这些两种流行的稳定币之间进行交易。
如果通过组合具有不同精度的这两种代币的金额来进行计算,而没有首先将次要代币的精度缩放到主要代币的精度,则会发生细微的精度损失错误。
检查来自 Notional 的 sherlock 审计的这个简化的代码,我已对其进行了简化,以便可以在一个简单的测试工具中单独运行,并使用日志记录功能来查看精度损失在内部发生的位置。此代码尝试计算交易池中 LP(流动性提供者)代币在由 2 个代币组成的交易池的主要代币中的价值:
// source: https://github.com/sherlock-audit/2023-02-notional/blob/main/leveraged-vaults/contracts/vaults/common/internal/pool/TwoTokenPoolUtils.sol#L67
// given a trading pool of two tokens & an amount of LP pool tokens,
// 给定一个包含两种代币的交易池和一定数量的 LP 池代币,
// return the total value of the given LP tokens in the pool's primary token
// 返回给定 LP 代币在池的主要代币中的总价值
function errorGetWeightedBalance(uint token1Amount,
uint token1Precision,
uint token2Amount,
uint ,//token2Precision,
uint poolTotalSupply,
uint lpPoolTokens,
uint lpPoolTokensPrecision,
uint oraclePrice) external view returns (uint256 primaryAmount) {
console.log("PrecisionLoss.errorGetWeightedBalance()");
// Get shares of primary and secondary balances with the provided poolClaim
// 使用提供的 poolClaim 获取主要和次要余额的份额
uint256 primaryBalance = token1Amount * lpPoolTokens / poolTotalSupply;
uint256 secondaryBalance = token2Amount * lpPoolTokens / poolTotalSupply;
console.log("primaryBalance : ", primaryBalance);
console.log("secondaryBalance : ", secondaryBalance);
// Value the secondary balance in terms of the primary token using the oraclePrice
// 使用 oraclePrice 以主要代币的形式评估次要余额
uint256 secondaryAmountInPrimary = secondaryBalance * lpPoolTokensPrecision / oraclePrice;
console.log("secondaryAmountInPrimary : ", secondaryAmountInPrimary);
// Make sure primaryAmount is reported in token1Precision
// 确保 primaryAmount 以 token1Precision 报告
// @audit (primaryBalance + secondaryAmountInPrimary)
// primaryBalance & secondaryAmountInPrimary may not be denominated in
// the same precision => they can't safely be added together without
// first scaling the secondary token to match the primary token's precision
// @审计 (primaryBalance + secondaryAmountInPrimary)
// primaryBalance 和 secondaryAmountInPrimary 可能不以
// 相同的精度表示 => 在不先缩放次要代币以匹配主要代币的精度的情况下,
// 它们不能安全地加在一起
primaryAmount = (primaryBalance + secondaryAmountInPrimary) * token1Precision / lpPoolTokensPrecision;
console.log("primaryAmount : ", primaryAmount);
}
(primaryBalance + secondaryAmountInPrimary) 尝试将两种可能不具有相同精度的代币的余额加在一起;这将导致精度损失。为了防止此缺陷,必须首先将次要金额缩放到主要代币的精度,然后再进行进一步的计算:
// @audit correct verison scales secondary token to match primary tokens' precision
// before performing further computation
// @审计 正确的版本缩放次要代币以匹配主要代币的精度
// 在执行进一步计算之前
function correctGetWeightedBalance(uint token1Amount,
uint token1Precision,
uint token2Amount,
uint token2Precision,
uint poolTotalSupply,
uint lpPoolTokens,
uint lpPoolTokensPrecision,
uint oraclePrice) external view returns (uint256 primaryAmount) {
console.log("PrecisionLoss.correctGetWeightedBalance()");
// Get shares of primary and secondary balances with the provided poolClaim
// 使用提供的 poolClaim 获取主要和次要余额的份额
uint256 primaryBalance = token1Amount * lpPoolTokens / poolTotalSupply;
uint256 secondaryBalance = token2Amount * lpPoolTokens / poolTotalSupply;
// @audit scale secondary token amount to first token's precision prior to any computation
// @审计 在进行任何计算之前,将次要代币金额缩放到第一个代币的精度
secondaryBalance = secondaryBalance * token1Precision / token2Precision;
console.log("primaryBalance : ", primaryBalance);
console.log("secondaryBalance : ", secondaryBalance);
// Value the secondary balance in terms of the primary token using the oraclePrice
// 使用 oraclePrice 以主要代币的形式评估次要余额
uint256 secondaryAmountInPrimary = secondaryBalance * lpPoolTokensPrecision / oraclePrice;
console.log("secondaryAmountInPrimary : ", secondaryAmountInPrimary);
// Make sure primaryAmount is reported in token1Precision
// 确保 primaryAmount 以 token1Precision 报告
primaryAmount = primaryBalance + secondaryAmountInPrimary;
console.log("primaryAmount : ", primaryAmount);
}
这是一个测试工具,它为 DAI/USDC 和 USDC/DAI 池运行错误版本和正确版本:
// given a pool of two tokens with different precision, calculate
// 给定一个具有不同精度的两种代币的池,计算
// value of given amount of LP tokens in the pool's primary token
// 给定数量的 LP 代币在池的主要代币中的价值
function testWeightedPoolBalanceDiffPrecision() public {
uint DAI_PRECISION = 10**18;
uint USDC_PRECISION = 10**6;
uint token1Precision = DAI_PRECISION;
uint token2Precision = USDC_PRECISION;
uint token1Amount = 100 * token1Precision;
uint token2Amount = 100 * token2Precision;
uint poolTotalSupply = 100;
uint lpPoolTokens = 50;
uint lpPoolTokensPrecision = DAI_PRECISION;
uint oraclePrice = 1 * DAI_PRECISION;
// first test: DAI/USDC pool
// 第一个测试:DAI/USDC 池
uint result = vulnContract.errorGetWeightedBalance(
token1Amount, token1Precision, token2Amount, token2Precision,
poolTotalSupply, lpPoolTokens, lpPoolTokensPrecision, oraclePrice );
assertEq(50000000000050000000, result);
result = vulnContract.correctGetWeightedBalance(
token1Amount, token1Precision, token2Amount, token2Precision,
poolTotalSupply, lpPoolTokens, lpPoolTokensPrecision, oraclePrice );
assertEq(100000000000000000000, result);
// second test: USDC/DAI pool
// 第二个测试:USDC/DAI 池
result = vulnContract.errorGetWeightedBalance(
token2Amount, token2Precision, token1Amount, token1Precision,
poolTotalSupply, lpPoolTokens, lpPoolTokensPrecision, oraclePrice );
assertEq(50000000, result);
result = vulnContract.correctGetWeightedBalance(
token2Amount, token2Precision, token1Amount, token1Precision,
poolTotalSupply, lpPoolTokens, lpPoolTokensPrecision, oraclePrice );
assertEq(100000000, result);
}
检查输出显示,错误的版本大大低估了用户 LP 代币的价值,大约降低了 -50%:
PrecisionLoss.errorGetWeightedBalance() (DAI/USDC)
primaryBalance : 50000000000000000000
secondaryBalance : 50000000
secondaryAmountInPrimary : 50000000
primaryAmount : 50000000000050000000
PrecisionLoss.correctGetWeightedBalance() (DAI/USDC)
primaryBalance : 50000000000000000000
secondaryBalance : 50000000000000000000
secondaryAmountInPrimary : 50000000000000000000
primaryAmount : 100000000000000000000
PrecisionLoss.errorGetWeightedBalance() (USDC/DAI)
primaryBalance : 50000000
secondaryBalance : 50000000000000000000
secondaryAmountInPrimary : 50000000000000000000
primaryAmount : 50000000
PrecisionLoss.correctGetWeightedBalance() (USDC/DAI)
primaryBalance : 50000000
secondaryBalance : 50000000
secondaryAmountInPrimary : 50000000
primaryAmount : 100000000
当组合可能具有不同精度的多个代币的金额时,我们必须始终注意在进行任何计算之前将所有金额转换为主要代币的精度。更多示例:[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
在上一节中,我们了解到代币金额可能具有不同的精度,因此在执行计算之前,将它们缩放到相同的精度非常重要。为了做到这一点,智能合约可能会过度缩放已经缩放的代币,导致代币金额变得过度膨胀。检查一下来自 Notional 的 sherlock 审计的这个发现。
审计员可以通过跟踪处理代币金额的代码路径,并查看代币金额是否被重复缩放来发现这种类型的漏洞,而无需进一步缩放,特别注意较大的模块化代码库,其中可能会调用不同的子组件来执行函数,并且可能会错误地重新缩放已缩放的金额。
通常,较大的代码库由多个开发人员创建,并且较大代码库中的不同模块在尝试缩放精度时可能具有轻微的怪癖。一个模块可以通过代币的小数位数来缩放精度,而另一个模块可能会硬编码一个常见的值,例如 1e18;当使用精度与硬编码值不同的代币时,这种不匹配的精度缩放可能会产生细微的精度损失错误。考虑一下来自 Yearn 的 code4rena 审计的这段代码 [ 1, 2] :
// @audit Vault.vy; vault precision using token's decimals
// @审计 Vault.vy;vault 精度使用代币的小数位数
decimals: uint256 = DetailedERC20(token).decimals()
self.decimals = decimals
/// ...
def pricePerShare() -> uint256:
return self._shareValue(10 ** self.decimals)
// @audit YearnYield; yield precision using hard-coded 1e18
// @审计 YearnYield;yield 精度使用硬编码的 1e18
function getTokensForShares(uint256 shares, address asset) public view override returns (uint256 amount) {
if (shares == 0) return 0;
// @audit should divided by vaultDecimals
// @审计 应该除以 vaultDecimals
amount = IyVault(liquidityToken[asset]).getPricePerFullShare().mul(shares).div(1e18);
}
审计员应检查较大代码库的所有模块是否使用相同的精度缩放,并注意一个模块中的硬编码精度值可能会与另一个模块中的动态精度值冲突,对于精度与硬编码值不匹配的代币。更多示例:[ 1, 2, 3, 4, 5, 6, 7, 8, 9]
当从一种类型向下转型为另一种类型时,Solidity 不会回退,而是会溢出,从而导致意外行为和可利用的错误。审计员应该注意在向下转型之前发生 require() 检查的代码模式;这些检查在向下转型之前可能会通过,但在由于向下转型溢出之后可能会失败。考虑一下来自 @gpersoon 的 balancer 漏洞赏金 的这个简化的代码:
function errorDowncast(uint sTimeU256, uint eTimeU256)
external view returns (uint sFromU32, uint eFromU32) {
// checks before downcast conversions may not be true
// 降转型转换之前的检查可能为 false
// after the downcast if```solidity
// @audit 向下取整有利于交易者,可能会从协议中泄漏价值
protocolFee = outputValue.mulWadDown(protocolFeeMultiplier);
// fixed: 向上取整有利于协议并防止价值泄漏给交易者
protocolFee = outputValue.mulWadUp(protocolFeeMultiplier);
// @audit 向下取整有利于交易者,可能会从协议中泄漏价值
tradeFee = outputValue.mulWadDown(feeMultiplier);
// fixed: 向上取整有利于协议并防止价值泄漏给交易者
tradeFee = outputValue.mulWadUp(feeMultiplier);
protocolFee 和 tradeFee 最初是向下取整的,这将导致系统随着时间的推移将价值泄漏给那些支付低于应付费用的交易者。在审计之后,通过向上取整 protocolFee 和 tradeFee 修复了这个问题,这有利于协议,防止价值从系统泄漏给交易者。
- 原文链接: dacian.me/precision-loss...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!