Aave Gas 优化审计:降低成本的技术

  • cyfrin
  • 发布于 6天前
  • 阅读 16

本文主要介绍了Cyfrin团队对Aave V3.3版本进行“公共利益”Gas优化审计的结果,通过一系列Solidity优化策略,在流动性和核心池操作等关键领域减少了59,732单位的gas消耗。文章详细描述了Gas优化的方法论和多种 Gas 优化技巧,包括缓存存储读取、使用命名返回变量、通过引用传递缓存的内存结构、删除不必要的上下文结构等,旨在帮助其他开发者在工作中应用类似的策略。

Dacian

Aave V3.3 ‘公共利益’ Gas 优化审计

探索 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 优化方面的专业帮助,请联系!我们很乐意提供帮助。

注意: 在所有代码示例中,以 "-" 开头的行被删除,以 "+" 开头的行被添加以实现优化。

Gas 优化方法

Aave 已经使用 基于 cheatcode 的 gas 快照 来测量单元测试期间特定代码段的 gas 成本,特别是对于核心协议功能。如果缺少快照,我们添加了自己的 [ 1, 2, 3, 4] 以建立一致的基线测量。

有了基线后,我们开始系统地分析代码以寻找优化机会。对于每次优化,我们都遵循相同的流程

  1. 实施建议的更改。

  2. 运行整个测试套件以确保没有任何中断。

  3. 将新的 gas 快照与基线进行比较以确认减少。

  4. 如果有效,请提交更改以及更新的快照,作为我们工作存储库中的单独提交。

  5. 在审计报告问题跟踪器中记录更改,使 Aave 能够查看优化、其影响和确切的代码差异。

每天晚上,我们都会运行 Aave 的 不变式模糊测试套件 以验证没有任何优化违反核心协议 不变式,这些检查超出了标准测试套件。

对于每次优化,我们都强调两个核心原则:

  • 使用快照数据量化节省。许多想法看起来很有希望,但会产生微不足道甚至负面的结果。

  • 使用项目的测试套件和模糊测试工具验证正确性,以确保行为保持不变。

我们每次优化的目标都很简单:保留行为,减少 gas

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 中,存储在内存中的 结构体 通过引用传递的。这意味着在子函数(包括 viewpure)内部对其所做的更改会保留在调用函数的上下文中。

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]:

  1. 在开始时读取用户的配置映射一次,并将其缓存在内存中。

  2. 通过引用将缓存的内存结构传递给任何需要它的函数,包括那些修改它的函数。

  3. 在事务结束时将内存副本写回存储一次。

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 的 RewardsDistributorCollector 合约中的两个位置 应用了此优化 [ 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
cyfrin
cyfrin
Securing the blockchain and its users. Industry-leading smart contract audits, tools, and education.