Gas问题:如何降低Solidity代码中的交易成本

本文深入探讨了EVM兼容链上智能合约的Gas优化技术。首先介绍了Gas的基本概念和费用构成,然后详细讲解了预估和测量Gas消耗的各种方法,包括使用Foundry进行离线测试、预执行测量和后执行测量。最后,文章总结了常见的Gas优化模式和高级技巧,旨在帮助开发者编写更高效、更经济的智能合约,提升用户体验并降低网络拥堵。

在 EVM 兼容链上编写智能合约不仅仅是关于正确性和安全性,还在于效率。你在链上执行的每条指令都会消耗 gas,而每个单位的 gas 都有实际成本。优化不佳的合约可能导致用户不必要的高额交易费用、用户体验下降,甚至在高峰使用期间加剧网络拥堵。

这篇文章将开始深入探讨 gas 优化技术。在我们探索具体的模式和技巧之前,让我们先建立一个通用的词汇表:

Gas: 一种衡量以太坊计算工作的单位。每个操作码、存储读/写和内存操作都会消耗预定数量的 gas。

Gas Price (gwei): 你愿意为每个单位的 gas 支付的金额,以 gwei 报价(1 gwei = 10⁹ wei)。

Tip (priority fee): 一项额外的费用(EIP-1559 中的 maxPriorityFeePerGas),用于激励矿工更快地包含你的交易,在基础费用之上支付。

Transaction Fee: 因交易类型(Legacy、EIP-2930、EIP-1559)而异,但根本上取决于使用的 gas 和有效的 gas 价格。在所有情况下,减少 gas 使用量都会降低总体成本。(这个主题在之前的文章中解释过:交易类型:第 1 部分第 2 部分

在我们了解了所有交易类型并编写了一些智能合约之后,让我们了解如何优化我们编写的合约上的 gas 使用量,以及为什么这很重要。

在本文中,我们将学习:

1. EVM Gas 成本基础

2. 测量你的 Gas

3. 常见的优化模式

4. 高级技巧

掌握了这些基础知识,我们就可以开始识别我们的合约在哪里花费最多的 gas,以及如何使每个操作更经济。

EVM Gas 成本基础

在应用优化之前,了解 gas 在操作码和数据结构级别上的消耗位置至关重要。总的来说,gas 成本主要来自四个类别:

存储 (SSTORE / SLOAD)

以太坊 gas 费用是按操作码和数据操作计算的。以下是 gas 支出最多的详细分类,基于 EIP-2200EIP-2929 的准确数据:

合约存储中的持久性键值对槽。最昂贵的操作:

SSTORE (write): 使用 EIP-2200 中定义的动态计量:

  • Zero → Non‑zero (original_value == 0): SSTORE_SET_GAS = 20 000 gas
  • Non‑zero → Different Non‑zero (original_value != 0): SSTORE_RESET_GAS = 5 000 gas
  • Value == current_value: 对于冷访问,GAS = 800,对于热访问 100(无状态更改)(在柏林分叉中引入了一个新的 EIP-2929,正如我们在 之前的文章 中学到的,它为热访问提供了折扣)
  • Refund (Non‑zero → Zero): SSTORE_CLEARS_SCHEDULE = 15 000 gas 返还

SLOAD (read): 冷访问 GAS = 800,热访问 100 (EIP-2929)

注意: 存储写入是迄今为止最昂贵的操作。重用存储位置或批量写入可以为每个交易节省数万 gas。

gas 成本示例:取自官方 EIP-2200 摘要并翻译成伪代码

## Constants (old → new values)
SLOAD_GAS              = 800        # was 200
SSTORE_SET_GAS         = 20000      # unchanged
SSTORE_RESET_GAS       = 5000       # unchanged
SSTORE_CLEARS_SCHEDULE = 15000      # unchanged
GAS_STIPEND            = 2300       # the 2 300-gas stipend for transfers

function performSSTORE(originalValue, currentValue, newValue):
    # 1. stipend check
    if gasleft() ≤ GAS_STIPEND:
        revert("out of gas")

    # 2. no-op store
    if currentValue == newValue:
        deductGas(SLOAD_GAS)
        return

    # 3. value actually changes
    if originalValue == currentValue:
        # 3.a first write in this TX
        if originalValue == 0:
            deductGas(SSTORE_SET_GAS)      # zero → non-zero
        else:
            deductGas(SSTORE_RESET_GAS)    # non-zero → different non-zero

        if newValue == 0:
            refund(SSTORE_CLEARS_SCHEDULE)  # track zero-clears

    else:
        # 3.b slot is “dirty” (already written in this TX)
        deductGas(SLOAD_GAS)

        if originalValue ≠ 0:
            if currentValue == 0:
                removeRefund(SSTORE_CLEARS_SCHEDULE)  # undo prior zero-clear
            if newValue == 0:
                refund(SSTORE_CLEARS_SCHEDULE)        # new zero-clear

        # 3.b.iii if we’ve reset back to original
        if originalValue == newValue:
            if originalValue == 0:
                refund(SSTORE_SET_GAS - SLOAD_GAS)
            else:
                refund(SSTORE_RESET_GAS - SLOAD_GAS)

注意:original_value 是事务开始时存储槽的值。current_value 是此 SSTORE 之前的槽的值,反映了同一事务中的任何先前写入。

内存

  • 扩展成本: 每个 32 字节字 3 gas,外加一个二次项

⌊a² ÷ 512⌋,在访问先前未使用的内存时应用。(在 以太坊黄皮书 中指定)

解释:它增加了一个小的超线性惩罚,以便随着你分配更多内存,边际成本缓慢增加(例如,对于 64 个字,⌊64²/512⌋ = ⌊4096/512⌋ = 8 个额外 gas)。

C_mem(a) = G_memory·a + ⌊a² ÷ 512⌋
  • 读/写: 扩展后,访问内存很便宜(每个字 3 gas)。

注意:通过预先调整数组大小或使用 calldata 处理大型输入来最大程度地减少内存扩展。

Calldata

  • 成本: 访问函数输入数据时,每个字节 3 gas(不可变且不可扩展。在 以太坊黄皮书 中指定)。
  • 含义: 对具有大型数组或结构的函数使用 calldata,以避免内存扩展成本。

堆栈和其他操作码

  • 算术 (ADD, MUL, etc.): 3 gas
  • 环境操作(CALLER, BALANCE): 根据操作码不同,2–400 gas
  • 加密操作 (ECRECOVER): ~3 000 gas
  • 日志 (LOGn): 基本 375 gas + 主题/数据成本(每个字节 8 gas)

通过了解这些类别和所涉及的确切 gas 成本,你可以识别合约中的热点,尤其是存储写入和内存扩展,并应用有针对性的优化来减少交易费用。

测量你的 Gas

分析 gas 使用情况是实现有意义的优化的第一步:如果没有准确的数据,你可能会将注意力集中在次要成本上,而忽略主要的 gas 消耗。在本节中,我们将介绍各种链下和链上测量技术,这些技术共同构成了一个全面的工具包,用于建立你的 gas 使用基线。

使用 foundry 进行链下测量(构建智能合约时)

以下是如何在你的测试套件中进行链下操作:

对于此示例,我们将使用 foundry 和一个智能合约(/src/Storage.sol),该示例来自 之前的博客文章 之一。

pragma solidity ^0.8.12;

contract Storage {
    struct my_storage_struct {
        uint256 number;
        string owner;
    }

    my_storage_struct my_storage;

    function store(my_storage_struct calldata new_storage) public {
        if (new_storage.number > 100) {
            revert("Number too large");
        }
        my_storage = new_storage;
    }

    function retrieve() public view returns (my_storage_struct memory){
        return my_storage;
    }
}

以及来自 ( /test/Storage.t.sol) 的测试

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.12;

import "forge-std/Test.sol";
import "../src/Storage.sol";

contract StorageTest is Test {
    Storage public storageContract;

    function setUp() public {
        storageContract = new Storage();
    }

    function testStoreStruct() public {
        Storage.my_storage_struct memory input = Storage.my_storage_struct({
            number: 25,
            owner: "bob"
        });

        storageContract.store(input);
    }

    function testStoreStructReverts() public {
        Storage.my_storage_struct memory input = Storage.my_storage_struct({
            number: 101,
            owner: "too much"
        });

        vm.expectRevert("Number too large");
        storageContract.store(input);
    }

}

有了这个智能合约及其测试,我们可以检查执行方法 store 将花费多少 gas。为此,我们将运行:

forge test --gas-report

我们应该看到如下输出:

我们在这里看到什么:

  • 在不同条件下调用了运行 store() 函数的测试套件 两次。(其中一个套件使用了 SSTORE,而另一个套件在 store 保护上失败)
  • Min (22 535 gas)

更便宜的情况(当函数调用在 if 保护上失败时)。(请记住,从第一篇博客文章中,基本交易成本为 21000 gas,这里我们有 1.5k 更多的 gas 用于读取和检查值)

  • Max (67 908 gas)

昂贵的情况(函数成功完成并且内存被写入),它支付了完整的 20 000 gas SSTORE 成本加上冷访问附加费和函数自身的的操作码开销。

  • Avg/Median (45 221 gas)

两次运行之间的中点,可用作典型调用的粗略预期。

注意:可以通过运行以下命令来运行特定方法:

forge test --match-test testStoreStruct --gas-report

预执行测量

当合约已部署并且我们想要检查运行特定输入将使用多少 gas 时可以使用。

对于此示例,让我们使用之前使用的相同的智能合约 /src/Storage.sol 并部署它。

让我们使用 Anvil(来自 foundry)来分叉区块链

anvil

并部署智能合约

forge create src/Storage.sol:Storage \
  --rpc-url http://localhost:8545 \
  --private-key <your-key> \
  --broadcast

意:anvil 输出中选择一个私钥

输出将如下所示:

[⠊] Compiling...
No files changed, compilation skipped
Deployer: <your-address>
Deployed to: <deployed-contract-address>
Transaction hash: <tx-hash>

现在让我们估算交易 gas 成本

## encode function selector
export SIG=$(cast sig "store((uint256,string))")
## encode function arguments
export ARGS=$(cast abi-encode "store((uint256,string))" "(25,\"bob\")")
## build the calldata: encoded function_selector + encoded params
## (#0x removes the 0x from the $ARGS)
export CALLDATA="${SIG}${ARGS#0x}"

## output should be:
## 0xddd356b3000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000190000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003626f620000000000000000000000000000000000000000000000000000000000

cast rpc eth_estimateGas \
  '{"from":"<your-address>","to":"contract-address","value":"0x0","data":"'"$CALLDATA"'"}' \
  'latest' \
  --rpc-url http://127.0.0.1:8545

## output shoud be:
## 0x10944 = 67908 // exactly the number we got previously in off-chain exampl

注意: 我们在这里使用了 foundry cli 工具,但同样的方法适用于任何 EVM 链上真实部署的合约调用

后执行测量

发送签名交易后,我们可以看到此交易在链上实际使用了多少 gas。

让我们使用前面的示例,并使用 foundry cli 工具来发布交易(或者按照之前文章 这里这里 中的学习,创建一个实际的区块链交易)

cast send <your-deployed-contract-address>\
  "store((uint256,string))" \
  '(25,"bob")' \
  --rpc-url http://127.0.0.1:8545 \
  --private-key <your-key>

在输出中,你应该看到如下内容:

...
transactionHash      0xe69ac7819787dc5ca8e5e5b0420936cdde171fce8760931bce5e0fc03e68cbac
...

然后让我们获取交易收据,看看实际使用了多少 gas

cast rpc eth_getTransactionReceipt \
  '"<your-transaction-hash>"' \
  --rpc-url http://127.0.0.1:8545

## the output shoud have this field
"gasUsed":"0x10944" # Same value we saw before

常见的优化模式

在本节中,我们将实现实用的技术来减少 Solidity 代码中的 gas。每个模式都针对先前确定的主要成本类别之一。

最大程度地减少存储写入

  • 变量打包: 将多个 uintXbool 分组到单个 32 字节的槽中。例如:
struct Packed {
    uint128 a;
    uint64 b;
    uint64 c;
}
  • 条件写入: 仅在新值与旧值不同时才更新状态:
if (x != newX) {
    x = newX;
}

缓存存储加载

  • 读入内存变量一次,然后在本地重用:
uint256 localVar = storageVar;
// 多次使用 localVar 而不是重复的存储读取

对外部数组使用 Calldata

  • 将大型外部输入标记为 calldata 而不是 memory,以避免内存扩展成本:
function batchTransfer(address[] calldata recipients) external {
    // 比使用 memory 更便宜
}

循环中未经检查的算术

  • 当安全时,跳过溢出检查,对于简单的增量循环:
for (uint256 i = 0; i < n; ) {
    // operations
    unchecked { i++; }
}

短路和逻辑简化

  • 使用提前返回和组合条件来最大程度地减少不必要的工作:
function withdraw(uint256 amount) external {
    if (amount == 0) return;
    require(balances[msg.sender] >= amount, "Insufficient");
    // proceed
}

链下预先计算常量

  • 硬编码不更改的值,而不是在链上计算它们:
uint256 constant RATE_MULTIPLIER = 1e18;

利用 Immutable 变量

  • 对在构造函数中设置一次的值使用 immutable,以节省存储读取成本:
address public immutable owner;
constructor() {
    owner = msg.sender;
}

优化事件与存储

  • 使用事件进行瞬时日志记录,而不是将数据写入合约存储:
event TransferLogged(address from, address to, uint256 amount); // emit TransferLogged() instead of storing to an array

对热路径使用汇编

  • 内联汇编可以减少函数调用开销并启用自定义优化,但要谨慎使用:
assembly {
    // 低级操作
}

意: 还有更多优化技巧可以搜索,但这些是你可以随时记住的基本知识。此外,尽量避免对动态数组进行循环,在大型输入中可能会因 gas 不足而失败

高级技巧

除了常见的模式之外,这些高级技术对于额外的 gas 节省可能很有用:(此处不再赘述,但会给出最少的解释,你可以探索)

  • Access List:我们在这篇 博客文章 中了解了这一点
  • Minimal Proxy ( EIP-1167 ): 部署微小的代理合约,这些合约将调用委托给共享实现,与完整合约相比,大大降低了部署 gas 成本。
  • SSTORE2 : 将大型静态字节数组直接存储在合约字节码中,并通过 extcodecopy 读取,避免了只读数据的高成本存储写入。
  • Gas Refund Optimization: 对状态更改进行排序以最大程度地提高退款资格。例如,仅在最终使用后清除存储槽,并避免将槽重置为非零。
  • Bitwise Flag Packing: 使用位掩码和移位将多个布尔值或小枚举组合到单个 uint256 中,以最大程度地减少使用的存储槽的数量。
  • Constructor Precomputation: 将繁重的计算移至线下并将预先计算的值传递到构造函数中,从而减少部署期间的链上算术和存储成本。
  • Batching & Multicall Aggregation: 使用 multicall我们将在即将到来的博客文章中介绍这一点)模式将多个逻辑操作聚合到一个事务中,从而分摊每次调用的开销并减少总 gas。
  • Advanced Assembly Patterns: 用于热循环、自定义哈希或按位运算的内联汇编可以绕过 Solidity 的安全检查并节省 gas,但会以可读性为代价。

总结

你现在已经对 gas 成本如何在 EVM 链上累积有了实际的了解,从主导交易费用的昂贵存储写入到内存、calldata 和常见操作码的细微成本。你还知道如何可靠地测量 gas 使用情况,使用链下 RPC 估计、链上收据和测试内报告器。有了这些数据,你可以应用经过验证的优化模式,最大程度地减少存储写入,利用 calldata,使用未经检查的循环等等,以立即降低 gas 消耗。对于需要更多精度的场景,访问列表、最小代理和数据编码策略等高级技术可提供进一步的节省。

你现在可以做什么:

  • 使用 Foundry 或 RPC 工具 分析你的合约 以识别 gas 热点。
  • 将 gas 报告集成 到你的测试套件中,以尽早发现回归。
  • 将优化模式应用 于最昂贵的函数。
  • 测量每次更改后的影响 以验证节省并指导进一步改进。

通过遵循这种方法,你将确保你的智能合约提供更快的执行速度、更低的费用以及任何 EVM 兼容网络上用户的更好体验。

  • 原文链接: medium.com/@andrey_obruc...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
andrey_obruchkov
andrey_obruchkov
江湖只有他的大名,没有他的介绍。