本文主要介绍了Cyfrin团队对Aave V3.3版本进行“公共利益”Gas优化审计的结果,通过一系列Solidity优化策略,在流动性和核心池操作等关键领域减少了59,732单位的gas消耗。文章详细描述了Gas优化的方法论和多种 Gas 优化技巧,包括缓存存储读取、使用命名返回变量、通过引用传递缓存的内存结构、删除不必要的上下文结构等,旨在帮助其他开发者在工作中应用类似的策略。
Dacian
探索 Aave V3.3 中发现的 gas 优化技术。学习实用的 Solidity 策略,以减少 gas 使用量,同时保持清晰、安全的代码。
Aave 是去中心化金融 ( DeFi) 的基石,拥有 最大的总锁定价值 (TVL) 超过 200 亿美元,并产生超过 每日 800,000 美元的费用。它的代码库经过大量审计、高度优化,并且经受住了时间的考验。
认识到 Aave 对生态系统的重要性,Cyfrin 最近对 Aave V3.3 的 commit 464a0ea 进行了 “公共利益” gas 优化审计,该审计未经 Aave 委托。鉴于该协议已经使用了许多高级的 gas 节省技术,这带来了一个有趣且艰巨的挑战。尽管如此,通过结合几种优化策略,我们在清算和核心池操作等关键领域总共减少了 59,732 个单位的 gas 使用量。
本文重点介绍我们使用的一些 技术,以便其他开发人员可以在他们的工作中使用类似的策略。如果你正在寻找 gas 优化方面的专业帮助,请联系!我们很乐意提供帮助。
注意: 在所有代码示例中,以 "-" 开头的行被删除,以 "+" 开头的行被添加以实现优化。
Aave 已经使用 基于 cheatcode 的 gas 快照 来测量单元测试期间特定代码段的 gas 成本,特别是对于核心协议功能。如果缺少快照,我们添加了自己的 [ 1, 2, 3, 4] 以建立一致的基线测量。
有了基线后,我们开始系统地分析代码以寻找优化机会。对于每次优化,我们都遵循相同的流程:
实施建议的更改。
运行整个测试套件以确保没有任何中断。
将新的 gas 快照与基线进行比较以确认减少。
如果有效,请提交更改以及更新的快照,作为我们工作存储库中的单独提交。
在审计报告问题跟踪器中记录更改,使 Aave 能够查看优化、其影响和确切的代码差异。
每天晚上,我们都会运行 Aave 的 不变式模糊测试套件 以验证没有任何优化违反核心协议 不变式,这些检查超出了标准测试套件。
对于每次优化,我们都强调两个核心原则:
使用快照数据量化节省。许多想法看起来很有希望,但会产生微不足道甚至负面的结果。
使用项目的测试套件和模糊测试工具验证正确性,以确保行为保持不变。
我们每次优化的目标都很简单:保留行为,减少 gas。
我们的审计发现了 Aave V3.3 中的 26 个不同的 gas 优化机会,采用了各种成熟的技术。
从存储读取是以太坊虚拟机 ( EVM) 中成本最高的操作之一。避免重复读取相同的值可以带来有意义的节省。Aave 已经很好地实现了这种模式,但我们发现了一些可以更一致地应用缓存的案例 [ 1, 2, 3, 4, 5, 6, 7, 8, 9]。
一个简单的例子 出现 在 RewardsDistributor
合约中。在事件发送期间,不必要地访问了两次相同的存储槽:
+ uint32 distributionEnd = rewardConfig.distributionEnd;
emit AssetConfigUpdated(
asset,
rewards[i],
oldEmissionPerSecond,
newEmissionsPerSecond[i],
- rewardConfig.distributionEnd,
- rewardConfig.distributionEnd,
+ distributionEnd,
+ distributionEnd,
newIndex
);
通过在内存中缓存 storageValue
,我们消除了冗余读取,而不会改变行为。
启发式: 是否在同一函数中多次读取存储槽,即使其值从未改变?子函数或修饰符是否读取与父函数相同的、相同的、不变的存储槽?
这种技术就像听起来一样简单。如果可能,使用命名返回 变量 可以节省 gas,因为它无需声明局部变量,尤其是在处理内存返回类型时。我们发现了一些命名返回可以显着节省成本的案例 [ 1, 2, 3, 4, 5, 6, 7, 8]。
两个明确的例子 [ 1, 2] 出现在 ReserveLogic
中:
function cumulateToLiquidityIndex(
DataTypes.ReserveData storage reserve,
uint256 totalLiquidity,
uint256 amount
- ) internal returns (uint256) {
+ ) internal returns (uint256 result) {
//next liquidity index is calculated this way: `((amount / totalLiquidity) + 1) * liquidityIndex`
//division `amount / totalLiquidity` done in ray for precision
- uint256 result = (amount.wadToRay().rayDiv(totalLiquidity.wadToRay()) + WadRayMath.RAY).rayMul(
+ result = (amount.wadToRay().rayDiv(totalLiquidity.wadToRay()) + WadRayMath.RAY).rayMul(
reserve.liquidityIndex
);
reserve.liquidityIndex = result.toUint128();
- return result;
}
function cache(
DataTypes.ReserveData storage reserve
- ) internal view returns (DataTypes.ReserveCache memory) {
- DataTypes.ReserveCache memory reserveCache;
+ ) internal view returns (DataTypes.ReserveCache memory reserveCache) {
reserveCache.reserveConfiguration = reserve.configuration;
reserveCache.reserveFactor = reserveCache.reserveConfiguration.getReserveFactor();
reserveCache.currLiquidityIndex = reserveCache.nextLiquidityIndex = reserve.liquidityIndex;
@@ -308,7 +305,5 @@
reserveCache.currScaledVariableDebt = reserveCache.nextScaledVariableDebt = IVariableDebtToken(
reserveCache.variableDebtTokenAddress
).scaledTotalSupply();
- return reserveCache;
}
启发式: 是否可以使用命名返回来删除局部变量?是否有任何内存返回变量缺少命名返回,从而可以消除显式的 return
语句和声明?
在 Solidity 中,存储在内存中的 结构体 是 通过引用传递的。这意味着在子函数(包括 view
和 pure
)内部对其所做的更改会保留在调用函数的上下文中。
Aave 将 UserConfigurationMap
定义为一个包含单个 uint256
位图的结构体:
struct UserConfigurationMap {
/**
* @dev Bitmap of the users collaterals and borrows. It is divided in pairs of bits, one pair per asset.
* The first bit indicates if an asset is used as collateral by the user, the second whether an
* asset is borrowed by the user.
*/
uint256 data;
}
在清算等关键池操作中,对用户配置映射的存储引用会传递到多个子函数中,并且可能会被多次修改。这导致了昂贵的 重复存储读取和写入 模式。理想情况下,每个 事务 应该只从任何存储槽读取和写入一次。
我们通过以下方式实现了该模式 [ 1, 2, 3, 4, 5]:
在开始时读取用户的配置映射一次,并将其缓存在内存中。
通过引用将缓存的内存结构传递给任何需要它的函数,包括那些修改它的函数。
在事务结束时将内存副本写回存储一次。
SupplyLogic
提供了一个清晰的示例,在提款期间:
// note: `userConfig` is storage reference:
// DataTypes.UserConfigurationMap storage userConfig,
- bool isCollateral = userConfig.isUsingAsCollateral(reserve.id);
+ // read user's configuration once from storage; this cached copy will be used
+ // and updated by all withdraw operations, then written to storage once at
+ // the end ensuring only 1 read/write from/to storage
+ DataTypes.UserConfigurationMap memory userConfigCache = userConfig;
+ bool isCollateral = userConfigCache.isUsingAsCollateral(reserve.id);
if (isCollateral && amountToWithdraw == userBalance) {
- userConfig.setUsingAsCollateral(reserve.id, false);
+ userConfigCache.setUsingAsCollateralInMemory(reserve.id, false);
emit ReserveUsedAsCollateralDisabled(params.asset, msg.sender);
}
@@ -145,12 +150,12 @@
reserveCache.nextLiquidityIndex
);
- if (isCollateral && userConfig.isBorrowingAny()) {
+ if (isCollateral && userConfigCache.isBorrowingAny()) {
ValidationLogic.validateHFAndLtv(
reservesData,
reservesList,
eModeCategories,
- userConfig,
+ userConfigCache,
params.asset,
msg.sender,
params.reservesCount,
@@ -161,7 +166,10 @@
emit Withdraw(params.asset, msg.sender, params.to, amountToWithdraw);
+ // update user's configuration from cache; but only if it was modified
+ if (isCollateral && amountToWithdraw == userBalance) {
+ userConfig.data = userConfigCache.data;
}
启发式: 结构存储引用是否传递给多个函数并在单个事务期间修改?如果是这样,它是否可以缓存在内存中、通过引用传递并在最后写回存储一次?
上下文结构体通常用于对函数变量进行分组 并避免在 Solidity 中出现可怕的 “堆栈太深” 编译器 错误。虽然这些结构体在复杂函数中很有用,但有时即使在不需要时也会默认添加,从而导致不必要的内存分配和更高的 gas 成本。
修复方法 [ 1, 2, 3] 很简单:删除任何未使用或不必要的上下文结构体,并直接内联其变量,只要这样做不会触发堆栈深度问题。
一个简单的例子出现在 Collector
合约中,其中不必要地使用了 CreateStreamLocalVars
:
- CreateStreamLocalVars memory vars;
- vars.duration = stopTime - startTime;
+ uint256 duration = stopTime - startTime;
/* Without this, the rate per second would be zero. */
- if (deposit < vars.duration) revert DepositSmallerTimeDelta();
+ if (deposit < duration) revert DepositSmallerTimeDelta();
/* This condition avoids dealing with remainders */
- if (deposit % vars.duration > 0) revert DepositNotMultipleTimeDelta();
+ if (deposit % duration > 0) revert DepositNotMultipleTimeDelta();
- vars.ratePerSecond = deposit / vars.duration;
+ uint256 ratePerSecond = deposit / duration;
/* Create and store the stream object. */
streamId = _nextStreamId++;
_streams[streamId] = Stream({
remainingBalance: deposit,
deposit: deposit,
isEntity: true,
- ratePerSecond: vars.ratePerSecond,
+ ratePerSecond: ratePerSecond,
启发式: 是否在不需要时使用上下文结构体?变量是否可以安全地在函数内部声明,而不会导致 “堆栈太深” 错误?
在真正需要上下文结构体来避免 “堆栈太深” 错误的情况下,结构体通常包含比必要更多的变量。这种习惯通常源于 复制粘贴或过度概括之前的模式。
解决方案很简单:仅保留必须在结构体中才能满足编译器的变量。应删除任何可以安全地移回函数中的变量,因为这会减少内存使用并降低 gas 成本。
在 Aave 中,我们成功地从 LiquidationCallLocalVars
中删除了 3 个变量 [ 1] 并从 CalculateUserAccountDataVars
中删除了 5 个变量 [ 1, 2],从而显着节省了 gas。
启发式: 是否可以将上下文结构体中的任何变量移动到函数体中,而不会触发 “堆栈太深” 错误?如果是这样,这样做是否会减少 gas 使用量?始终通过快照比较确认影响。
在许多合约中,创建新的项目(如奖励或流)时,计数器会递增 1。虽然在功能上是正确的,但分离读取和递增操作可能会导致冗余的存储访问。
如果可能,在同一语句中读取并递增计数器 (使用 x++
) 会更节省 gas,尤其是在只需要该值一次时。
我们在 Aave 的 RewardsDistributor
和 Collector
合约中的两个位置 应用了此优化 [ 1, 2]:
// RewardsDistributor
- _assets[rewardsInput[i].asset].availableRewardsCount
+ _assets[rewardsInput[i].asset].availableRewardsCount++
] = rewardsInput[i].reward;
- _assets[rewardsInput[i].asset].availableRewardsCount++;
// Collector
- uint256 streamId = _nextStreamId;
+ streamId = _nextStreamId++;
_streams[streamId] = Stream({
remainingBalance: deposit,
deposit: deposit,
@@ -271,9 +271,6 @@
tokenAddress: tokenAddress
});
- /* Increment the next stream id. */
- _nextStreamId++;
启发式: 重构存储计数器以在同一表达式中使用 x++ 是否可以减少 gas 使用量?
当函数预计会提前返回或 revert 时,它应该 仅执行之前所需的最少工作。应避免不必要的计算,尤其是存储读取,尤其是在结果取决于单个输入参数或单个存储槽时。
Aave 通常可以很好地处理这种情况,优先进行输入检查,并且仅在为了最大化影响,gas 优化审计应该在安全审查之前进行,允许审计员分析代码的最终优化版本。
- 原文链接: cyfrin.io/blog/aave-v3-3...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!