《Solidity Gas 优化秘籍:80+ 技巧》

文章详细介绍了在Solidity中进行Gas优化的多种技巧,涵盖了Gas优化的基本原理、具体实现方法以及在不同场景下的应用。内容全面,结构清晰,适合有一定Solidity基础的开发者深入学习。

solidity gas optimization


目录

RareSkills Gas优化手册

节省部署Gas

跨合约调用

设计模式

Calldata 优化

汇编技巧

Solidity 编译器相关

危险的技术

过时的技巧

通过 RareSkills 学习更多


Solidity Gas优化

在以太坊中,Gas优化是重写 Solidity 代码,以实现相同的业务逻辑,同时在以太坊虚拟机(EVM)中消耗更少的Gas单元。本文超过 11,000 字,不包含源代码,是关于Gas优化的最详细的处理文献。要充分理解本教程中的技巧,你需要了解 EVM 的工作原理,你可以通过参加我们的Gas优化课程Yul 课程 并练习 Huff Puzzles 来学习。然而,如果你只是想知道哪些代码区域可能需要进行Gas优化,本文提供了多个需要关注的领域。

作者

RareSkills 研究员 Michael Amadi (LinkedInTwitter) 和 Jesse Raymond (LinkedInTwitter) 对这项工作做出了重要贡献。

Gas优化技巧并不总是有效

某些Gas优化技巧仅在特定上下文中有效。例如,从直觉上看,

if (!cond) {
    // 分支 False
}
else {
    // 分支 True
}

看起来比

if (cond) {
    // 分支 True
}
else {
    // 分支 False
}

效率低,因为在反转条件时需要消耗额外的操作码。相反,实际上在许多情况下这种优化会增加交易成本。Solidity 编译器有时是不可预测的。因此,你应该实际测量替代方案的效果,然后再确定特定算法。可以将以下这些技巧视为提高意识的指标,指向编译器可能产生意外的领域。文档中将部分不是通用的技巧标记为此。Gas优化技巧有时依赖于编译器的局部行为。通常,你应该同时测试代码的最佳版本和非最佳版本,以查看是否实际上获得了改进。我们将记录一些令人惊讶的情况,其中应该导致优化的内容实际上导致更高的成本。此外,当使用 --via-ir 选项在 Solidity 编译器上时,这些优化行为可能会发生变化。

注意复杂性和可读性

Gas优化通常会使代码变得不太可读且更复杂。优秀的工程师必须在哪些优化是值得的和哪些不值得之间做出主观权衡。

无法全面处理每个主题

我们无法详细解释每个优化,实际也并不必要,因为有其他在线资源。例如,对无二次确认的 Layer 2 和状态通道做出完整或至少重要的处理将超出范围,网上有其他资源可以详细学习这些主题。本文的目的是提出最全面的技巧列表。如果某个技巧听起来不熟悉,可以作为进一步自学的提示。如果标题看起来是你已经知道的技巧,只需略读该部分。

我们不讨论特定应用的技巧

例如,存在Gas效率高的方法来确定一个数字是否是质数,但这几乎很少需要,因此为此分配空间会降低本文的价值。类似地,在我们的Tornado Cash 教程中,我们建议可以使代码库变得更有效的方法,但将该处理包含在这里将不会对此篇文章的读者有益,因为它太具有应用特定性。

1. 最重要的是:尽量避免存储写入从零到一

初始化存储变量是合约最昂贵的操作之一。当一个存储变量从零变为非零时,用户必须支付总共 22,100 Gas(20,000 Gas用于从零到非零写入,2,100 Gas用于冷存储访问)。这就是为什么 Openzeppelin 的重入保护器用 1 和 2 来表示函数是活动或不活动,而不是用 0 和 1。将存储变量从非零改为非零只需 5,000 Gas。

2. 缓存存储变量:准确写入和读取存储变量一次

你在高效的 Solidity 代码中会频繁看到以下模式。读取一个存储变量至少花费 100 Gas,因为 Solidity 不会缓存存储读取。写入的成本则高得多。因此,你需要手动缓存变量,以执行一次存储读取和一次存储写入。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract Counter1 {
    uint256 public number;

    function increment() public {
        require(number < 10);
        number = number + 1;
    }
}

contract Counter2 {
    uint256 public number;

    function increment() public {
        uint256 _number = number;
        require(_number < 10);
        number = _number + 1;
    }
}

第一个函数读取计数器两次,第二段代码读取一次。

3. 打包相关变量

将相关变量打包到同一存储槽中有助于降低Gas成本,从而减少高成本存储相关操作。手动打包是最高效的 我们使用位移将两个 uint80 值存储和检索到一个变量(uint160)中。这将只使用一个存储槽,并在单个交易中存储或读取单个值时更便宜。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract GasSavingExample {
    uint160 public packedVariables;

    function packVariables(uint80 x, uint80 y) external {
        packedVariables = uint160(x) << 80 | uint160(y);
    }

    function unpackVariables() external view returns (uint80, uint80) {
        uint80 x = uint80(packedVariables >> 80);
        uint80 y = uint80(packedVariables);
        return (x, y);
    }
}

EVM 打包略微低效 此示例也使用一个槽,但在单个交易中存储或读取值时可能略显昂贵。这是因为 EVM 会自动执行位移。

contract GasSavingExample2 {
    uint80 public var1;
    uint80 public var2;

    function updateVars(uint80 x, uint80 y) external {
        var1 = x;
        var2 = y;
    }

    function loadVars() external view returns (uint80, uint80) {
        return (var1, var2);
    }
}

未打包成最低效 此示例未使用任何优化,在存储或读取值时费用更高。与其他示例不同,这使用了两个存储槽来存储变量。

contract NonGasSavingExample {
    uint256 public var1;
    uint256 public var2;

    function updateVars(uint256 x, uint256 y) external {
        var1 = x;
        var2 = y;
    }

    function loadVars() external view returns (uint256, uint256) {
        return (var1, var2);
    }
}

4. 打包结构体

打包结构体条目,类似于打包相关状态变量,有助于节省Gas。(重要的是要注意,在 Solidity 中,结构体成员在合约的存储中是顺序存储的,从它们初始化的槽位置开始)考虑以下示例:

未打包的结构体

未打包的结构体有三个项目,将存储在三个独立的槽中。然而,如果这些项目被打包,只需使用两个槽,使读取和写入结构体条目变得更便宜。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract Unpacked_Struct {
    struct unpackedStruct {
        uint64 time; // 占用一个槽 - 尽管只使用 64 位 (8 字节) 中的 256 位 (32 字节)。
        uint256 money; // 这将占用一个新槽,因为它是完整的 256 位 (32 字节) 值,因此无法与前一个值打包。
        address person; // 一个地址仅占用 160 位 (20 字节)。
    }

    // 开始于槽 0
    unpackedStruct details = unpackedStruct(53_000, 21_000, address(0xdeadbeef));

    function unpack() external view returns (unpackedStruct memory) {
        return details;
    }
}

打包的结构体

我们可以通过打包结构体条目来减少上述示例的Gas消耗。

contract Packed_Struct {
    struct packedStruct {
        uint64 time; // 在这种情况下,`time` (64 位) 和 `person` (160 位) 可以一起打包到同一槽中,因为它们都可以装入 256 位 (32 字节)
        address person; // 与 `time` 同槽。 Together it occupies 224 bits (28 bytes) out of 256 bits (32 bytes).
        uint256 money; // 这将占用一个新槽,因为它是完整的 256 位 (32 字节) 值,因此无法与前一个值打包。
    }

    // 开始于槽 0
    packedStruct details = packedStruct(53_000, address(0xdeadbeef), 21_000);

    function unpack() external view returns (packedStruct memory) {
        return details;
    }
}

5. 保持字符串不超过 32 字节

在 Solidity 中,字符串是可变长度的动态数据类型,意味着它们的长度可以根据需要更改和增长。如果长度达到 32 字节或更长,则定义它们的槽中会存储 string * 2 + 1 的长度,而它们的实际数据则存储在其他地方(该槽的 keccak 哈希)。然而,如果字符串少于 32 字节,则 length * 2 存储在它的存储槽的最低有效字节中,字符串的实际数据则从定义它的槽的最高有效字节开始存储。

字符串示例(少于 32 字节)

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract StringStorage1 {
    // 只使用一个槽
    // 槽 0: 0x(len * 2)00...hex of (len * 2)(hex"hello")
    // 由于大小的原因,费用较小。
    string public exampleString = "hello";

    function getString() public view returns (string memory) {
        return exampleString;
    }
}

字符串示例(超过 32 字节)

contract StringStorage2 {
    // 长度超过 32 字节。 
    // 槽 0: 0x00...(length*2+1)。
    // keccak256(0x00): 将 "hello" 的十六进制表示存储
    // 由于大小增加而导致费用增加。
    string public exampleString = "This is a string that is slightly over 32 bytes!";

    function getStringLongerThan32bytes() public view returns (string memory) {
        return exampleString;
    }
}

我们可以通过以下 Foundry 测试脚本进行测试:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

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

contract StringStorageTest is Test {
    StringStorage1 public store1;
    StringStorage2 public store2;

    function setUp() public {
        store1 = new StringStorage1();
        store2 = new StringStorage2();
    }

    function testStringStorage1() public {
        // 测试字符串长度少于 32 字节
        store1.getString();
        bytes32 data = vm.load(address(store1), 0); // 槽 0
        emit log_named_bytes32("Full string plus length", data); // 整个字符串及其 length*2 存储在槽 0,因为其长度小于 32 字节
    }

    function testStringStorage2() public {
        // 测试字符串长于 32 字节
        store2.getStringLongerThan32bytes();
        bytes32 length = vm.load(address(store2), 0); // 槽 0 存储 length*2+1
        emit log_named_bytes32("Length of string", length);

        // 取消注释以获取原始长度作为数字
        // emit log_named_uint("Real length of string (no. of bytes)", uint256(length) / 2); 
        // 除以 2 来获取原始长度

        bytes32 data1 = vm.load(address(store2), keccak256(abi.encode(0))); // 槽 keccak256(0)
        emit log_named_bytes32("First string chunk", data1);

        bytes32 data2 = vm.load(address(store2), bytes32(uint256(keccak256(abi.encode(0))) + 1));
        emit log_named_bytes32("Second string chunk", data2);
    }
}

这是运行测试后的结果。如果我们将大于 32 字节字符串的长度的十六进制值连接在一起而不考虑长度,我们就可以将它改回原始字符串(使用 Python)。如果字符串的长度小于 32 字节,最好将其存储在一个 bytes32 变量中,并在需要时使用汇编来使用它。例如:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.21;
contract EfficientString {
    bytes32 shortString;

    function getShortString() external view returns(string memory) {
        string memory value;

        assembly {
            // 获取槽 0
            let slot0Value := sload(shortString.slot)

            // 为了获取保存长度信息的字节,我们进行掩码以清除字符串并除以 2 获取长度
            let len := div(and(slot0Value, 0xff), 2)

            // 提取字符串时,我们掩码槽值来去除长度// 我们确保它的长度不能超过 1 字节,因为在 `storeShortString` 函数中有长度检查
            let str := and(slot0Value, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00)

            // 将长度存入内存
            mstore(0x80, len)

            // 将字符串存入内存
            mstore(0xa0, str)

            // 使 `value` 指向 0x80 使得 solidity 可以帮助我们返回 
            value := 0x80// 更新自由内存指针
            mstore(0x40, 0xc0)
        }

        return value;
    }

    function storeShortString(string calldata value) external {
        assembly {
            // 强制要求长度小于 32
            if gt(value.length, 31) {
                revert(0, 0)
            }

            // 乘以泡度,以便于存储 length*2 符合 solidity 的约定
            let length := mul(value.length, 2)

            // 获取字符串内容
            let str := calldataload(value.offset)

            // 按 OR 操作,提取所需的值存储
            let toBeStored := or(str, length)

            // 存储在槽中
            sstore(shortString.slot, toBeStored)
        }
    }
}

以上代码可以进一步优化,但保持这样的方式是为了便于理解。

6. 从不更新的变量应为不可变或常量

在 Solidity 中,未打算更新的变量应设为常量或不可变。这是因为常量和不可变值直接嵌入到合约的字节码中,从而不使用存储。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract Constants {
    uint256 constant MAX_UINT256 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;

    function get_max_value() external pure returns (uint256) {
        return MAX_UINT256;
    }
}

// 此合约消耗的Gas比上述合约要多
contract NoConstants {
    uint256 MAX_UINT256 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;

    function get_max_value() external view returns (uint256) {
        return MAX_UINT256;
    }
}

这大大节省了Gas,因为我们不进行任何成本较高的存储读取。

7. 使用映射而不是数组以避免长度检查

当存储一列表或一组希望按特定顺序进行组织并以固定键/索引提取的项目时,通常使用数组数据结构。这工作良好,但你知道可以通过使用映射来实现节省每次读取 2,000+ Gas的技巧吗?请看下面的示例

/// get(0) Gas费用: 4860 
contract Array {
    uint256[] a;

    constructor() {
        a.push() = 1;
        a.push() = 2;
        a.push() = 3;
    }

    function get(uint256 index) external view returns(uint256) {
        return a[index];
    }
}

/// get(0) Gas费用: 2758
contract Mapping {
    mapping(uint256 => uint256) a;

    constructor() {
        a[0] = 1;
        a[1] = 2;
        a[2] = 3;
    }

    function get(uint256 index) external view returns(uint256) {
        return a[index];
    }
}

不过仅仅是使用映射,我们就节省了 2102 Gas。为什么呢?在底层,当读取数组索引的值时,solidity 添加字节码去检查你是否在读取有效索引(即:索引严格小于数组的长度),否则将会触发 panic 错误(Panic(0x32))。这防止它读取未分配或更糟糕地说,分配的存储/内存位置。由此映射(仅是键=>值对)的方式是,不需要执行这样的检查,因此我们可以直接从存储槽读取。重要的是要注意,在以这种方式使用映射时,你的代码应确保没有超出你标准数组的索引。

8. 使用 unsafeAccess 访问数组以避免冗余的长度检查

使用映射以外的另一种避免 solidity 在从数组读取时进行长度检查(但仍然使用数组)的替代方法是使用 Openzeppelin 的 Arrays.sol 库中的 unsafeAccess 函数。这允许开发者绕过长度溢出检查,直接访问数组给定索引的值。不过依然需要仅在确定传入函数的索引不会超过数组的长度时使用。

9. 当使用较多布尔值时使用位图而非布尔值

常见的一种模式是在空投时,在声称空投或 NFT 铸造时将地址标记为“已使用”。但是,由于只需要一个位来存储此信息,而每个槽为 256 位,这意味着可以在一个存储槽上存储 256 个标志或布尔值。你可以从以下资源学习这种技术:RareSkills 学生的视频教程 位图预售教程

10. 使用 SSTORE2 或 SSTORE3 来存储大量数据

SSTORE

SSTORE 是一种 EVM 操作码,允许我们根据键值存储持久数据。因为在 EVM 中,键和值都是 32 字节的值。写入(SSTORE)和读取(SLOAD)的成本从Gas支出的角度来看非常昂贵。写入 32 字节的成本为 22,100 Gas,这转化为每字节约 690 Gas。另一方面,写入智能合约的字节码的成本为每字节 200 Gas。

SSTORE2

SSTORE2 是一种独特的概念,使用合约的字节码来写入和存储数据。要实现这一点,我们利用字节码固有的不变性。SSTORE2 的一些特性:

  • 我们只能写入一次。有效地使用 CREATE 替换 SSTORE
  • 为了读取,不再使用 SLOAD,而是调用 EXTCODECOPY 在部署的地址上读取特定数据作为字节码。
  • 当需要存储更多数据时,写入数据变得便宜得多。

示例:

写入数据

我们的目标是将特定数据(以字节格式表示)存储为合约的字节码。要实现这个目标,我们需要做两件事:1. 首先将我们的数据复制到内存中,因为 EVM 然后从内存中获取这些数据并将其存储为运行时代码。你可以在我们的文章中了解更多关于 合约创建代码 的内容。

  1. 返回并存储新部署的合约地址以供将来使用。
    • 我们在下面的代码 0x61000080600a3d393df300 中,在61和80之间的四个零(0000)位置上添加合约代码大小。因此如果代码大小为65,它将变为 0x61004180600a3d393df300(0x0041 = 65)
    • 该字节码负责我们提到的第1步。
    • 现在我们为第2步返回新部署的地址。

最终合约字节码 = 00 + 数据(00 = STOP 被添加以确保字节码不能通过错误调用地址执行)。

读取数据
  • 要获取相关数据,你需要存储数据的地址。
  • 如果代码大小为 0,我们将回退,显而易见。
  • 现在我们只需从相关的起始位置返回合约的字节码,即在1字节之后(记住第一个字节是 STOP OPCODE(0x00))。

好奇的附加信息:

  • 我们还可以使用预确定地址,利用 CREATE2 计算链下或链上的指针地址,而无需依赖于存储指针。

参考:solady

SSTORE3

要理解 SSTORE3,首先让我们回顾一下 SSTORE2 的一个重要特性。

  • 新部署的地址取决于我们打算存储的数据。

写入数据 SSTORE3 实现了一种设计,使得新部署的地址独立于我们提供的数据。首先,我们使用 SSTORE 将提供的数据存储在存储中。然后我们作为数据在 CREATE2 中传递一个常量 INIT_CODE,其内部读取存储中保存的提供的数据以将其部署为代码。这种设计选择使我们能够高效地通过提供盐(salt,从 0 到 20 字节)来计算数据的指针地址。因此,使我们能够将指针与其他变量打包,从而降低存储成本。读取数据尝试想象一下我们如何读取数据。

  • 答案是我们可以通过提供盐轻松计算部署的地址。
  • 然后,在我们收到指针地址后,使用相同的 EXTCODECOPY 操作码获取所需的数据。

总结

  • SSTORE2 在写操作较少而读取操作很频繁的情况下很有帮助(如果指针 > 14 字节)
  • SSTORE3 更适合当你很少写,但经常读取时(如果指针 < 14 字节)

感谢 Philogy 提供的 SSTORE3。

11. 在适当的情况下使用存储指针而不是内存指针

在 Solidity 中,存储指针是引用合约中存储位置的变量。它们与 C/C++ 等语言中的指针并不完全相同。了解如何高效使用存储指针以避免不必要的存储读取和进行气体高效的存储更新是很有帮助的。下面是一个示例,展示了存储指针可以在何处发挥作用。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract StoragePointerUnOptimized {
    struct User {
        uint256 id;
        string name;
        uint256 lastSeen;
    }

    constructor() {
        users[0] = User(0, "John Doe", block.timestamp);
    }

    mapping(uint256 => User) public users;

    function returnLastSeenSecondsAgo(uint256 _id) public view returns (uint256) {
        User memory _user = users[_id];
        uint256 lastSeen = block.timestamp - _user.lastSeen;
        return lastSeen;
    }
}

上面的代码中,我们有一个函数返回给定索引下用户的最后一次查看时间。它获取 lastSeen 值,并从当前 block.timestamp 中减去。然后,我们将整个结构体复制到内存中,并获取 lastSeen 值来计算最后查看的时间。这种方法工作良好,但效率不高,这是因为我们将整个结构体从存储复制到了内存,包括不需要的变量。只需有一种方式从 lastSeen 存储位置读取(而不使用汇编)。这就是存储指针的出现。

// 与上一个版本相比,此实现节省了大约 5,000 gas。
contract StoragePointerOptimized {
    struct User {
        uint256 id;
        string name;
        uint256 lastSeen;
    }

    constructor() {
        users[0] = User(0, "John Doe", block.timestamp);
    }

    mapping(uint256 => User) public users;

    function returnLastSeenSecondsAgoOptimized(uint256 _id) public view returns (uint256) {
        User storage _user = users[_id]; 
        uint256 lastSeen = block.timestamp - _user.lastSeen;
        return lastSeen;
    }
}

“上述实现与第一个版本相比节省了大约 5,000 gas”。为何如此,唯一的变化在于将 memory 改为 storage,而我们被告知存储是昂贵的,应该避免使用?在这里,我们将 users[_id] 的存储指针存储在堆栈中的固定大小变量中(结构体的指针基本上是结构体开始的存储槽,在这个情况下将是 user[_id].id 的存储槽)。因为存储指针是懒惰的(意味着它们只有在被调用或引用时才起作用)。接下来,我们只访问结构体的 lastSeen 键。通过这种方式,我们进行了一次存储加载,然后将其存储到堆栈,而不是进行 3 次或可能更多的存储加载并在提取小块到堆栈之前进行内存存储。注意:使用存储指针时,务必小心不要引用 悬挂指针。(这是 RareSkills 技术之一讲师的 悬挂指针视频教程)。

12. 避免 ERC20 代币余额归零,始终保持少量

这与上面避免零写入的部分有关,但值得单独提出,因为实现稍显微妙。如果一个地址频繁清空(并重新加载)其账户余额,这将导致大量零到一的写入。

13. 从 n 倒数到零,而不是从零数到 n

将存储变量设置为零时,给予退款,因此,如果存储变量的最终状态为零,计算所花费的气体将更少。

14. 存储中的时间戳和区块编号不需要是 uint256

大小为 uint48 的时间戳将在未来数百万年内工作。区块编号每 12 秒递增一次。这应该让你对合理大小的数字有个概念。


节省气体部署

1. 使用账户 nonce 来预测相互依赖的智能合约地址,从而避免存储变量和地址设置函数

在传统合约部署中,智能合约的地址可以基于部署者的地址和它的 nonce 被确定性计算出。Solady 的 LibRLP 库 可以帮助我们做到这一点。考虑以下示例场景;StorageContract 仅允许 Writer 设置存储变量 x,这意味着它需要知道 Writer地址。但为了使 WriterStorageContract 进行写入,它还需要知道 StorageContract地址。以下实现是一种应对这一问题的天真方式。它通过在部署后有一个设置函数来处理。但存储变量是昂贵的,我们更愿意避免它。

contract StorageContract {
    address immutable public writer;
    uint256 public x;

    constructor(address _writer) {
        writer = _writer;
    }

    function setX(uint256 x_) external {
        require(msg.sender == address(writer), "only writer can set");
        x = x_;
    }
}

contract Writer {
    StorageContract public storageContract;

    // 成本: 49291
    function set(uint256 x_) external {
        storageContract.setX(x_);
    }

    function setStorageContract(address _storageContract) external {
        storageContract = StorageContract(_storageContract);
    }
}

这在部署时和运行时都花费更多。这涉及到部署 Writer,然后部署 StorageContract,并将部署的 Writer 地址 设置为 writer。然后用新创建的 StorageContract 设置 WriterStorageContract 变量。这涉及许多步骤,可能会很昂贵,因为我们将 StorageContract 存储在存储中。调用 Writer.setX() 的成本为49k gas。更有效的做法是提前计算出 StorageContractWriter 将要部署的地址,并在它们的构造器中将它们设置。这里是一个示例:

import {LibRLP} from "https://github.com/vectorized/solady/blob/main/src/utils/LibRLP.sol";

contract StorageContract {
    address immutable public writer;
    uint256 public x;

    constructor(address _writer) {
        writer = _writer;
    }

    // 成本: 47158
    function setX(uint256 x_) external {
        require(msg.sender == address(writer), "only writer can set");
        x = x_;
    }
}

contract Writer {
    StorageContract immutable public storageContract;

    constructor(StorageContract _storageContract) {
        storageContract = _storageContract;
    }

    function set(uint256 x_) external {
        storageContract.setX(x_);
    }
}

// 一次性部署者。
contract BurnerDeployer {
    using LibRLP for address;

    function deploy() public returns(StorageContract storageContract, address writer) {
        StorageContract storageContractComputed = StorageContract(address(this).computeAddress(2)); // 合约的 nonce 从 1 开始,并且在它创建合约时只递增 1。
        writer = address(new Writer(storageContractComputed)); // 首次创建在这里发生,使用 nonce = 1
        storageContract = new StorageContract(writer); // 第二次创建在这里发生,使用 nonce = 2
        require(storageContract == storageContractComputed, "false compute of create1 address"); // 保证检查
    }
}

在这里,调用 Writer.setX() 的成本为47k gas。通过预计算 StorageContract 将被部署的地址节省了2000多gas,这样就没有必要使用设置函数。使用这种技术不需要单独使用合约,你也可以在部署脚本中执行此操作。如果你希望深入了解,我们提供了一篇关于地址预测的 视频教程,由 Philogy 制作。

2. 使构造函数可支付

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;

contract A {}

contract B {
    constructor() payable {}
}

使构造函数可支付可节省200 gas。在部署时,非可支付函数中隐式插入了 require(msg.value == 0)。此外,在部署时更少的字节码意味着因较小的 calldata 减少了费用。使常规函数不支付是有充分的理由的,但通常合约是由特权地址部署的,可以合理地假设他们不会发送以太。如果不熟练的用户部署合约,这可能不适用。

3. 可通过优化 IPFS 哈希使其零做更多的零来减少部署大小(或使用 –no-cbor-metadata 编译器选项)

我们已经在关于 智能合约元数据 的教程中解释过这一点,但为了回顾,Solidity 编译器在实际智能合约代码后追加51字节的元数据。由于每个部署字节的成本为200 gas,去除它们可以节省超过 10,000 gas 的部署成本。然而,这并不总是理想的效果,因为这会影响智能合约的验证。相反,开发者可以在代码注释中挖掘使 IPFS 哈希的附加内容带有更多的零。

4. 如果合约是一次性使用,请在构造函数中使用 selfdestruct

有时,合约是在一个交易中用于部署多个合约,这需要在构造函数中完成。如果合约仅用于构造函数中的代码,则在操作结束时自销毁将节省 gas。尽管自销毁功能在即将到来的硬分叉中设定将被移除,但根据 EIP 6780 的要求,在构造函数中仍会得到支持。

5. 了解选择内部函数和修饰符时的权衡

修饰符在其使用的位置注入其实现字节码,而内部函数跳转到运行时代码中其实现的位置。这给这两种选择带来了一些权衡。

  • 多次使用修饰符意味着重复并增加运行时代码的大小,但因为缺少跳转到内部函数执行偏移并返回继续,使得 gas 成本降低。这意味着如果运行时的 gas 成本对你最重要,那么修饰符应该是你的选择,但如果部署 gas 成本和/或减少创建代码的大小对你最重要,那么使用内部函数将是最佳。
  • 但是,修饰符有这样的权衡,即它们只能在函数的开始或结束执行。这意味着在函数中间执行它将无法直接实现,至少不使用内部函数,这样会破坏原始目的。这影响了它的灵活性。然而,内部函数可以在函数的任何点调用。

下面的示例展示了使用修饰符和内部函数的 gas 成本差异。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

/** 部署 gas 成本:195435
    每次调用的 gas成本:
              restrictedAction1: 28367
              restrictedAction2: 28377
              restrictedAction3: 28411
 */
 contract Modifier {
    address owner;
    uint256 val;

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner);
        _;
    }

    function restrictedAction1() external onlyOwner {
        val = 1;
    }

    function restrictedAction2() external onlyOwner {
        val = 2;
    }

    function restrictedAction3() external onlyOwner {
        val = 3;
    }
}

/** 部署 gas 成本: 159309
    每次调用的 gas 成本:
              restrictedAction1: 28391
              restrictedAction2: 28401
              restrictedAction3: 28435
 */
 contract InternalFunction {
    address owner;
    uint256 val;

    constructor() {
        owner = msg.sender;
    }

    function onlyOwner() internal view {
        require(msg.sender == owner);
    }

    function restrictedAction1() external {
        onlyOwner();
        val = 1;
    }

    function restrictedAction2() external {
        onlyOwner();
        val = 2;
    }

    function restrictedAction3() external {
        onlyOwner();
        val = 3;
    }
}
操作 部署 restrictedAction1 restrictedAction2 restrictedAction3
修饰符 195435 28367 28377 28411
内部函数 159309 28391 28401 28435

从上表中,我们可以看到,使用修饰符的合约在部署时比使用内部函数的合约多花费了至少35k gas,因为重复了三个函数中的 onlyOwner 功能。在运行时中,我们看到每个使用修饰符的函数比使用内部函数的函数固定少花费 24 gas。

6. 当部署非常相似但不频繁调用的智能合约时,使用克隆或元代理

当部署多个相似的智能合约时,Gas成本可能很高。为了降低这些成本,可以使用最小克隆或元代理,这些代理在其字节码中存储实现合约的地址,并作为代理与其交互。然而,克隆的运行时成本与部署成本之间存在权衡。由于它们使用 delegatecall 与普通合约的交互成本更高,因此只应在不需要频繁与其交互时使用。例如,Gnosis Safe 合约使用克隆以减少部署成本。了解更多关于如何使用克隆和元代理降低智能合约部署费用的话题,可以参考我们的博客条目:

7. 管理员函数可以是可支付的

我们可以使特定于管理员的函数可支付以节省 gas,因为编译器不会检查函数的调用值。这也将使合约更小且更便宜进行部署,因为在创建和运行时代码中 Opcode 更少。

8. 自定义错误通常比 require 语句小

自定义错误比包含字符串的 require 语句便宜,因为自定义错误的处理方式是不同的。Solidity 仅存储错误签名的哈希的前 4 个字节,并仅返回这些字节。这意味着在回滚时,只有 4 个字节需要存储在内存中。而在 require 语句中,带有字符串消息则必须存储(在内存中)并回滚至少 64 字节。下面是一个示例。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract CustomError {
    error InvalidAmount();

    function withdraw(uint256 _amount) external pure {
        if (_amount > 10 ether) revert InvalidAmount();
    }
}

// 这使用的 gas 多于上述合约
contract NoCustomError {
    function withdraw(uint256 _amount) external pure {
        require(_amount &lt;= 10 ether, "Error: Pass in a valid amount");
    }
}

9. 使用已有的 create2 工厂而不是部署自己的

标题不言自明。如果你需要地址确定性,通常可以重用一个已预部署的地址。


跨合约调用

1. 使用代币的转账钩子,而不是从目标智能合约发起转账

假设你有合约 A,它接受代币 B(一个 NFT 或 ERC1363 代币)。天真的工作流程如下:

  1. msg.sender 给予 contract A 接受 token B 的权限
  2. msg.sender 调用 contract A 将代币从 msg.sender 转移到 A
  3. Contract A 然后调用 token B 进行转移
  4. Token B 执行转移,并在 contract A 中调用 onTokenReceived()
  5. Contract AonTokenReceived() 返回值给 token B
  6. Token B 返回执行给 contract A

这是非常低效的。更好的做法是 msg.sender 调用 contract B 进行转移,并触发 contract A 中的 tokenReceived 钩子。注意:

  • 所有 ERC1155 代币都包含转账钩子
  • ERC721 中的 safeTransfersafeMint 也有转账钩子
  • ERC1363 有 transferAndCall
  • ERC777 有转账钩子但已被弃用。如果你需要可替代的代币,请使用 ERC1363 或 ERC1155

如果你需要传递参数给合约 A,只需使用数据字段并在合约 A 中解析即可。

2. 转移以太时使用 fallback 或 receive,而不是 deposit()

与上述相似,你可以“仅转移”以太到合约,并让它在转移时进行响应,而不是使用可支付函数。当然,这取决于合约的其余架构。示例 Deposit in AAVE

contract AddLiquidity {

    receive() external payable {
        IWETH(weth).deposit{msg.value}();
        AAVE.deposit(weth, msg.value, msg.sender, REFERRAL_CODE)
    }
}

fallback 函数可以接收字节数据,该数据可以使用 abi.decode 进行解析。这作为向存入函数提供参数的替代方案。

3. 在进行跨合约调用时使用 ERC2930 访问列表事务,以提前预热存储槽和合约地址

访问列表事务允许你预先支付一些存储和调用操作的 gas 费用,并享受 200 gas 折扣。这可以在进一步状态或存储访问中节省 gas,该费用被视为温访问。如果你的事务将进行跨合约调用,你几乎应该使用访问列表事务。当调用克隆或代理时,这始终涉及通过 delegatecall 进行跨合约调用,你应将事务作为访问列表事务。

我们有一篇专门的博文,访问 <https://www.rareskills.io/post/eip-2930-optional-access-list-ethereum> 来了解更多。

4. 在有意义的地方缓存对外部合约的调用(例如,缓存从 chainlink oracle 返回的数据)

一般建议缓存数据以避免在单次执行过程中重复使用同一数据超过 1 次。显然的例子是,如果你需要进行多个操作,例如,使用从链链链接获取的 ETH 价格,你可以将价格存储在内存中,而不是再次进行昂贵的外部调用。

5. 在路由器类合约中实现 multicall

这是一种常见功能,例如 Uniswap Router 和 Compound Bulker。如果你预期你的用户进行一系列调用,让一个合约通过 multicall 将它们批量处理。

6. 将架构设计为单体以避免合约调用

合约调用是昂贵的,节省其成本的最好方法就是根本不使用它们。虽然这会有天然的权衡,但将多个合约彼此交互有时会增加气体和复杂性,而不是加以管理。


设计模式

1. 使用 multidelegatecall 批量交易

Multi-delegatecall 帮助 msg.sender 调用合约中的多个函数,同时保留 env vars,如 msg.sendermsg.value注意:要注意,由于 msg.value 是持续的,可能会导致开发者需要处理的问题。Multi delegatecall 的示例是 Uniswap 的实现如下所示:

function multicall(bytes[] calldata data) public payable override returns (bytes[] memory results) {
        results = new bytes[](data.length);
        for (uint256 i = 0; i &lt; data.length; i++) {
            (bool success, bytes memory result) = address(this).delegatecall(data[i]);

            if (!success) {
                // 下一行的 5 行来自 https://ethereum.stackexchange.com/a/83577
                if (result.length &lt; 68) revert();
                assembly {
                    result := add(result, 0x04)
                }
                revert(abi.decode(result, (string)));
            }

            results[i] = result;
        }
    }

2. 使用 ECDSA 签名替代梅克尔树用于允许列表和空投

梅克尔树使用大量 calldata,随着梅克尔证明的增加而增加成本。一般来说,使用数字签名在 gas 费用方面比梅克尔证明便宜。

3. 将ERC20Permit用于单笔交易中批量审批和转让步骤

ERC20 Permit 具有一个附加函数,该函数接受来自代币持有者的数字签名,以提高另一地址的审批。因此,审批的接收者可以提交许可事务,并将转移提交成一笔交易。授予许可的用户无需支付任何gas,获得许可的接收者也可以将许可与 transferFrom 交易批量合并为单笔交易。

4. 如果适用,使用 L2 消息传递用于游戏或其他高吞吐率、低交易价值应用

Etherorcs 是这种模式的早期先驱之一,因此你可以查看他们的 Github(链接在上面)以寻求灵感。这个思想就是在以太坊上的资产可以通过(消息传递)“桥接”到另外的链上,如 Polygon、Optimism 或 Arbitrum,并且可以在成本低廉的地方进行游戏。

5. 使用状态通道(state-channels)如果适用

状态通道可能是以太坊最古老但仍可用的可扩展性解决方案。与 L2 不同,它们是特定于应用的。用户不是将事务提交给链,而是向智能合约提交资产,然后相互分享绑定签名以作为状态转移。操作结束后,他们然后将最终结果提交到链上。如果参与者中的一个不诚实,则诚实的参与者可以使用对方的签名强制智能合约释放其资产。

6. 使用投票委托作为节省气体的措施

我们的教程中关于 ERC20 Votes 的内容更详细地描述了该模式。与每个代币持有者投票不同,只有代表投票,这使投票的数量减少。

7. 与 ERC721 相比,ERC1155 是一种更便宜的非同质化代币

ERC721 的 balanceOf 函数在实践中使用很少,但每当发生铸造和转移时,都会增加存储开销。ERC1155 根据每个 id 进行记录,并且也使用相同的余额来跟踪 id 的所有权。如果每个 id 的最大供应量为一个,则该代币就变得可以非同质化。

8. 使用一个 ERC1155 或 ERC6909 代币,而不是几个 ERC20 代币

这就是 ERC1155 代币的最初目的。每个单独的代币表现得像一个 ERC20,但只需部署一个合约。这种方法的缺点是这些代币与大多数 DeFi 交换原语不兼容。ERC1155 在所有转移方法中使用回调。如果不希望这样,则可以使用 ERC6909

9. UUPS 升级模式在用户方面比透明可升级代理更加节省 gas

透明可升级代理 模式需要每次事务发生时对比 msg.sender 与管理员。UUPS 仅在升级函数时执行此操作。

10. 考虑使用 OpenZeppelin 以外的替代品

OpenZeppelin 是一个出色且流行的智能合约库,但还有其他值得考虑的替代方案。这些替代方案提供更好的气体效率,并已被开发人员测试和推荐。这样的两个替代方案的例子是 SolmateSolady。Solmate 是一个为常见智能合约模式提供许多高效实现的库。Solady 是另一个高度重视使用汇编语言的高效气体库。


Calldata 优化

1. 使用个性化地址(安全!)

使用带有前导零的个性化地址更便宜,这可以节省 calldata gas 成本。一个好的例子是 OpenSea 的 Seaport 合约,地址为:0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC。直接调用该地址不会节省 gas。但是,如果将该合约的地址作为函数参数使用,则由于在 calldata 中有更多零,该函数调用将节省 gas。这对于以零个很多的 EOA 作为函数参数也是如此——原因相同。只需注意,发生了 生成个性化地址的黑客攻击,对于使用不足够随机私钥生成的个人钱包的个性化地址。这对于通过查找 create2 的盐生成的智能合约个性化地址并不是问题,因为智能合约没有私钥。

2. 如果可能,避免在 calldata 中使用有符号整数

由于 solidity 使用补码 来表示有符号整数,负小数字的 calldata 会大部分为非零。例如,-1 在补码形式中为 0xff..ff,因此成本更高。

3. Calldata 通常比内存便宜

直接从 calldata 加载函数输入或数据比从内存加载便宜。这是因为从 calldata 访问数据涉及较少的操作和 gas 成本。因此,建议仅在函数中需要修改数据时使用 memory(calldata 无法被修改)。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
contract CalldataContract {
    function getDataFromCalldata(bytes calldata data) public pure returns (bytes memory) {
        return data;
    }
}

contract MemoryContract {
    function getDataFromMemory(bytes memory data) public pure returns (bytes memory) {
        return data;
    }
}

4. 考虑打包 calldata,尤其是在 L2 上

Solidity 会自动打包存储变量,但 abi 编码 对于将被打包的变量在 calldata 中则不会。这是一种相对极端的优化,会导致更高的代码复杂性,但如果一个函数需要很多 calldata,这值得考虑。ABI 编码并不适用于每种数据表示,有些数据表示可以以特定应用程序的方式更高效地编码。在我们的 L2 calldata 优化 的文章中讨论了该技术。更新 在 Dencun 升级之后,大多数 L2 不再将 calldata 发送到 L1,而是使用 blob,因此,尽管减少 calldata 大小仍然会节省成本,但节省并不是那么显著。


汇编技巧

你不应该假设编写汇编代码会自动导致更高效的代码。我们列出了编写汇编通常效果更好的领域,但你应该始终测试非汇编版本。

1. 使用汇编进行回滚时带有错误消息

在 Solidity 代码中进行回滚时,通常的做法是使用 require 或 revert 语句,用错误消息回滚执行。在大多数情况下,可以通过使用汇编进行优化,从而带上错误消息进行回滚。下面是一个示例:

/// 调用 restrictedAction(2) 使用非所有者地址:24042
contract SolidityRevert {
    address owner;
    uint256 specialNumber = 1;

    constructor() {
        owner = msg.sender;
    }

    function restrictedAction(uint256 num)  external {
        require(owner == msg.sender, "caller is not owner");
        specialNumber = num;
    }
}

/// 调用 restrictedAction(2) 使用非所有者地址:23734
contract AssemblyRevert {
    address owner;
    uint256 specialNumber = 1;

    constructor() {
        owner = msg.sender;
    }

    function restrictedAction(uint256 num)  external {
        assembly {
            if sub(caller(), sload(owner.slot)) {
                mstore(0x00, 0x20) // 存储需存放错误消息长度的偏移
                mstore(0x20, 0x13) // 存储长度(19)
                mstore(0x40, 0x63616c6c6572206973206e6f74206f776e657200000000000000000000000000) // 存储消息的十六进制表示
                revert(0x00, 0x60) // 使用数据回滚
            }
        }
        specialNumber = num;
    }
}

从示例中可见,使用汇编回滚错误消息所节省的 gas 超过 300,这主要来自内存扩展费用和 Solidity 编译器在内部进行的额外类型检查。

2. 通过接口调用函数会产生内存扩展成本,因此请使用汇编重复使用已存在内存中的数据

从合约 A 调用合约 B 的某个函数,使用接口会十分方便,创建 B 的实例并调用我们希望调用的函数。这样做非常好,但由于 Solidity 编译你的代码,它会将要发送给合约 B 的数据存储在新的内存位置,因此增加了内存,有时候是不必要的。通过内联汇编,我们可以更好地优化代码,通过使用我们之前使用的内存位置节省一些 gas,或(如果合约 B 期望的 calldata 少于 64 字节)在临时位置存储我们的 calldata。下面的示例比较这两者:

/// 30570
contract Sol {
    function set(address addr, uint256 num) external {
        Callme(addr).setNum(num);
    }
}

/// 30350
contract Assembly {
    function set(address addr, uint256 num) external {
        assembly {
            mstore(0x00, hex"cd16ecbf") // 设置我们期望调用的函数签名
            mstore(0x04, num) // 设置参数

            if iszero(extcodesize(addr)) {
                revert(0x00, 0x00) // 如果地址没有部署代码则回滚
            }

            let success := call(gas(), addr, 0x00, 0x00, 0x24, 0x00, 0x00) // 调用合约 B 的 setNum 函数

            if iszero(success) {
                revert(0x00, 0x00) // 如果没有成功则回滚
            }
        }
    }
}

contract Callme {
    uint256 num = 1;

    function setNum(uint256 a) external {
        num = a;
    }
}

我们看到调用 set(uint256) 的 Assembly 成本比使用 Solidity 低 220 gas。需要注意的是,当使用内联汇编进行外部调用时,使用 extcodesize(addr) 检查我们将要调用的地址是否已部署代码并在此返回 0 进行回滚是很重要的。这很重要,因为调用没有部署代码的地址会始终返回 true,这在大多数情况下可能会对合约逻辑造成毁灭性的影响。

3. 常见的数学操作,如 min 和 max 有气体高效的替代方案

未优化

function max(uint256 x, uint256 y) public pure returns (uint256 z) {
    z = x > y ? x : y;
}

优化

function max(uint256 x, uint256 y) public pure returns (uint256 z) {
    /// @solidity memory-safe-assembly
    assembly {
        z := xor(x, mul(xor(x, y), gt(y, x)))
    }
}

以上代码取自 Solady 库 数学部分,可以找到更多的数学操作。值得探索该库,以查看可用的气体高效操作。上述示例之所以更气体高效,是因为三元运算符(一般来说,包含条件的代码)包含跳转 opcode,而这些条件跳转是更昂贵的。以下是我们对无分支 max 的 视频教程,解释了上述代码。

4. 在某些情况下使用 SUB 或 XOR 来检查不等于(更有效)

在使用内联汇编比较两个值的等值性(例如,owner是否与 caller() 相同)时,执行此操作有时更加高效

if sub(caller, sload(owner.slot)) {
    revert(0x00, 0x00) // 回滚错误
}

与执行以下语句相比:

if eq(caller, sload(owner.slot)) {
    revert(0x00, 0x00) // 回滚错误
}

XOR 也可以完成相同的事情,但请注意,XOR 将认为所有位都被翻转的值也相等,因此确保这不会成为攻击向量。这种技巧会依赖于所使用的编译器版本以及代码的上下文。

5. 使用内联汇编检查地址(0)编写内联汇编的合约通常被认为是气体优化的。我们可以直接操控内存,使用更少的操作码,而不是把它交给 Solidity 编译器。身份验证机制就是一个使用内联汇编的好例子,比如实现地址零检查。以下是一个示例:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract NormalAddressZeroCheck {
    function check(address _caller) public pure returns (bool) {
        require(_caller != address(0x00), "Zero address");
        return true;
    }
}

contract AddressZeroCheckAssembly {
    // 节省大约 90 gas
    function checkOptimized(address _caller) public pure returns (bool) {
        assembly {
            if iszero(_caller) {
                mstore(0x00, 0x20)
                mstore(0x20, 0x0c)
                mstore(0x40, 0x5a65726f20416464726573730000000000000000000000000000000000000000) // 加载 "Zero Address" 的十六进制到内存
                revert(0x00, 0x60)
            }
        }

        return true;
    }
}

6. selfbalance 比 address(this).balance 更便宜(在某些场景中更高效)

Solidity 代码 address(this).balance 在某些情况下可以更高效地使用 yul 的 selfbalance() 函数,但注意编译器有时足够聪明,会巧妙地在底层使用这个技巧,因此要两种方法都进行测试。

7. 使用汇编对大小为 96 字节或更小的数据执行操作:哈希和未索引的数据在事件中

Solidity 总是通过扩展内存来写入内存,这在某些情况下不是高效的。我们可以通过利用内联汇编来优化对大小为 96 字节或更小的数据进行的内存操作。Solidity 将前 64 字节的内存 (mem[0x00:0x40]) 保留为可供开发者使用的临时空间,使用这些空间进行任意操作并保证不会意外写入或读取。接下来的 32 字节内存 (mem[0x40:0x60]) 用于存储、读取和更新自由内存指针。接下来 32 字节的内存 (mem[0x60:0x80]) 称为零槽。这是未初始化的动态内存数据(bytes memory、string memory、T[] memory(其中 T 是任何有效类型))所指向的地方。由于这些值是未初始化的,Solidity 认为它们指向的槽(0x60)保持 0x00。注意:即使在动态结构(即内部有动态值)中,存储在内存中的结构在未初始化时也不指向零槽。注意:即使它们嵌套在结构中,未初始化的动态内存数据仍然指向零槽。如果我们可以利用临时空间来执行内存中的操作,而编译器通常会扩展内存来执行这些操作,那么我们就可以优化代码。因此,我们现在可以使用 64 字节的更便宜的内存。自由内存指针空间也可以被使用,只要我们在退出汇编块之前将其更新。为了这个目的,我们可以暂时将其存储在栈上。让我们来看一些示例。

  • 使用汇编记录最多 96 字节的未索引数据
contract ExpensiveLogger {
    event BlockData(uint256 blockTimestamp, uint256 blockNumber, uint256 blockGasLimit);

    // 成本:26145
    function returnBlockData() external {
        emit BlockData(block.timestamp, block.number, block.gaslimit);
    }
}

contract CheapLogger {
    event BlockData(uint256 blockTimestamp, uint256 blockNumber, uint256 blockGasLimit);

    // 成本:22790
    function returnBlockData() external {
        assembly {
            mstore(0x00, timestamp())
            mstore(0x20, number())
            mstore(0x40, gaslimit())

            log1(0x00, 
                0x60,
                0x9ae98f1999f57fc58c1850d34a78f15d31bee81788521909bea49d7f53ed270b // BlockData 的事件哈希
            )
        }
    }
}

上面的示例展示了如何通过使用内存存储希望在 BlockData 事件中发出的数据来节省近 2000 gas。这里不需要更新我们的自由内存指针,因为在我们发出事件后执行结束,并且我们不会再返回到 Solidity 代码中。接下来的例子是需要更新自由内存指针的情况。

  • 使用汇编对最多 96 字节的数据进行哈希
contract ExpensiveHasher {
    bytes32 public hash;
    struct Values {
        uint256 a;
        uint256 b;
        uint256 c;
    }
    Values values;

    // 成本:113155
    function setOnchainHash(Values calldata _values) external {
        hash = keccak256(abi.encode(_values));
        values = _values;
    }
}

contract CheapHasher {
    bytes32 public hash;
    struct Values {
        uint256 a;
        uint256 b;
        uint256 c;
    }
    Values values;

    // 成本:112107
    function setOnchainHash(Values calldata _values) external {
        assembly {
            // 缓存自由内存指针,因为我们即将覆盖它 
            let fmp := mload(0x40)

            // 使用 0x00 到 0x60
            calldatacopy(0x00, 0x04, 0x60)
            sstore(hash.slot, keccak256(0x00, 0x60))

            // 恢复自由内存指针的缓存值
            mstore(0x40, fmp)
        }

        values = _values;
    }
}

在上面的示例中,与第一个示例类似,我们使用汇编将值存储在头 96 字节的内存中,这使我们节省了超过 1000 gas。同时注意到,在这种情况下,由于我们仍然要返回到 Solidity 代码,因此在汇编块的开始和结束时缓存和更新了自由内存指针。这是为了确保 Solidity 编译器对内存中存储内容的假设保持一致。

8. 使用汇编在进行多个外部调用时重用内存空间。

引起 Solidity 编译器扩展内存的操作是进行外部调用。在进行外部调用时,编译器必须在内存中编码其希望调用的外部合约上的函数签名以及其参数。由于我们知道,Solidity 不会清理或重用内存,所以它必须将这些数据存储在下一个自由内存指针中,这进一步扩展了内存。使用内联汇编,我们可以使用临时空间和自由内存指针偏移来存储这些数据(如上所述),如果函数参数在内存中不超过 96 字节。更好的是,如果我们进行多个外部调用,我们可以重用同一内存空间,将其存储在第一个调用的内存中,而无需不必要地扩展内存。在这种情况下,Solidity 会按返回的数据长度扩展内存。这是因为返回的数据通常存储在内存中。如果返回数据小于 96 字节,我们可以利用临时空间存储它,以防止扩展内存。请参见以下示例:

contract Called {
    function add(uint256 a, uint256 b) external pure returns(uint256) {
        return a + b;
    }
}

contract Solidity {
    // 成本:7262
    function call(address calledAddress) external pure returns(uint256) {
        Called called = Called(calledAddress);
        uint256 res1 = called.add(1, 2);
        uint256 res2 = called.add(3, 4);

        uint256 res = res1 + res2;
        return res;
    }
}

contract Assembly {
    // 成本:5281
    function call(address calledAddress) external view returns(uint256) {
        assembly {
            // 检查 calledAddress 是否已部署代码
            if iszero(extcodesize(calledAddress)) {
                revert(0x00, 0x00)
            }

            // 第一次调用
            mstore(0x00, hex"771602f7")
            mstore(0x04, 0x01)
            mstore(0x24, 0x02)
            let success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20)
            if iszero(success) {
                revert(0x00, 0x00)
            }
            let res1 := mload(0x60)

            // 第二次调用
            mstore(0x04, 0x03)
            mstore(0x24, 0x4)
            success := staticcall(gas(), calledAddress, 0x00, 0x44, 0x60, 0x20)
            if iszero(success) {
                revert(0x00, 0x00)
            }
            let res2 := mload(0x60)

            // 相加结果
            let res := add(res1, res2)

            // 返回数据
            mstore(0x60, res)
            return(0x60, 0x20)
        }
    }
}

通过使用临时空间来存储函数选择器及其参数,并重用同一内存空间来进行第二次调用,同时将返回数据存储在零槽中,我们节省了大约 2000 gas。如果你希望调用的外部函数的参数大于 64 字节,并且只进行一次外部调用,使用汇编来编写并不会节省显著的 gas。然而,如果进行多次调用,你依然可以通过使用内联汇编重用同一内存槽来节省 gas。注意:如果指向其偏移量已使用的自由内存指针,始终记得进行更新,以防止 Solidity 覆盖存储在该位置的数据或以意外方式使用该位置的值。此外,如果在该调用栈中具有未定义的动态内存值,请避免覆盖零槽(0x60 内存偏移)。一种替代方案是明确地定义动态内存值或在使用时在退出汇编块之前将槽设置回 0x00。

9. 使用汇编在创建多个合约时重用内存空间。

Solidity 将合约创建视为与外部调用相似,返回 32 字节(即它返回创建合约的地址,或者如果合约创建失败则返回 address(0))。根据前面关于通过外部调用节省 gas 的部分,我们可以立即看到,优化这种情况的一种方法是在临时空间中存储返回的地址,以避免扩展内存。请参见下面的类似示例:

contract Solidity {
    // 成本:261032
    function call() external returns (Called, Called) {
        Called called1 = new Called();
        Called called2 = new Called();
        return (called1, called2);
    }
}

contract Assembly {
    // 成本:260210
    function call() external returns(Called, Called) {
        bytes memory creationCode = type(Called).creationCode;
        assembly {
            let called1 := create(0x00, add(0x20, creationCode), mload(creationCode))
            let called2 := create(0x00, add(0x20, creationCode), mload(creationCode))

            // 如果 called1 或 called2 返回 address(0) 则回退
            if iszero(and(called1, called2)) {
                revert(0x00, 0x00)
            }

            mstore(0x00, called1)
            mstore(0x20, called2)

            return(0x00, 0x40)
        }
    }
}

contract Called {
    function add(uint256 a, uint256 b) external pure returns(uint256) {
        return a + b;
    }
}

通过使用内联汇编,我们节省了接近 1000 gas。注意:在要部署的两个合约不相同的情况下,第二个合约的创建代码需要手动使用内联汇编进行存储,而不是在 Solidity 中赋值,以避免内存扩展。

10. 通过检查最后一位来测试一个数是偶数还是奇数,而不是使用模运算符

检查一个数是否为偶数或奇数的传统方法是 x % 2 == 0,其中 x 是待检查的数。你可以检查 x & uint256(1) == 0,其中 x 被假定为 uint256。位运算比模运算更加便宜。在二进制中,最右边的一位表示“1”,而其他位都是 2 的倍数,也就是偶数。对偶数加“1”会使其变为奇数。


Solidity 编译器相关

以下技巧被认为可以改善 Solidity 编译器的气体效率。然而,预计 Solidity 编译器会随着时间的推移而改进,让这些技巧的效果减少,甚至可能适得其反。你不应该盲目使用这里列出的技巧,而是要对比这两种选择。某些技巧已经由编译器在使用 --via-ir 编译器标志时纳入,可能导致在此标志下代码效率下降。基准测试。始终建立基准测试。

1. 偏好严格不等式重于非严格不等式,但测试这两种选择

通常建议使用严格不等式 (<, >) 而不是非严格不等式 (<=, >=)。这是因为编译器有时将 a > b 改成 !(a < b) 以实现非严格不等式。EVM 没有检查小于或等于及大于或等于的操作码。但是,应该尝试这两种比较,因为并不是说使用严格不等式总是会节省 gas。这非常依赖于周围操作码的上下文。

2. 拆分具有布尔表达式的 require 语句

当我们拆分 require 语句时,本质上是说每个语句必须为真,函数才能继续执行。如果第一个语句计算为 false,函数将立即回退,并且后续的 require 语句将不再评估。这将节省 gas 费用,而不是评估下一个 require 语句。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract Require {
    function dontSplitRequireStatement(uint256 x, uint256 y) external pure returns (uint256) {
        require(x > 0 && y > 0); // 两个条件都将被评估,然后才会回退
        return x * y;
    }
}

contract RequireTwo {
    function splitRequireStatement(uint256 x, uint256 y) external pure returns (uint256) {
        require(x > 0); // 如果 x &lt;= 0,调用即回退,而 "y > 0" 不被检查。 
        require(y > 0);

        return x * y;
    }
}

3. 分开 revert 语句

与拆分 require 语句类似,不在 if 语句中使用布尔运算符通常会省下 gas。

contract CustomErrorBoolLessEfficient {
    error BadValue();

    function requireGood(uint256 x) external pure {
        if (x &lt; 10 || x > 20) {
            revert BadValue();
        }
    }
}

contract CustomErrorBoolEfficient {
    error TooLow();
    error TooHigh();

    function requireGood(uint256 x) external pure {
        if (x &lt; 10) {
            revert TooLow();
        }
        if (x > 20) {
            revert TooHigh();
        }
    }
}

4. 始终使用命名返回值

当在返回语句中声明变量时,Solidity 编译器输出的代码更高效。实际上,这种情况几乎没有例外,因此如果看到匿名返回,应该用命名返回进行测试,以确定哪种情况效率最高。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract NamedReturn {
    function myFunc1(uint256 x, uint256 y) external pure returns (uint256) {
        require(x > 0);
        require(y > 0);

        return x * y;
    }
}

contract NamedReturn2 {
    function myFunc2(uint256 x, uint256 y) external pure returns (uint256 z) {
        require(x > 0);
        require(y > 0);

        z = x * y;
    }
}

5. 反转具有否定的 if-else 语句

这是我们在文章开头给出的相同示例。在下面的代码片段中,第二个函数避免了不必要的否定。理论上,额外的 ! 增加了计算成本。搏然正如我们在文章开始时提到的,应对比这两种方法,因为编译器有时可以对此进行优化。

function cond() public {
    if (!condition) {
        action1();
    }
    else {
        action2();
    }
}

function cond() public {
    if (condition) {
        action2();
    }
    else {
        action1();
    }
}

6. 在适当的时候使用 unchecked math

Solidity 默认使用检查数学(也就是说,如果数学操作的结果溢出结果变量的类型,则会回退),但在某些情况下,溢出是不可行的。

  • 对于有自然上界的循环
  • 输入到函数中的数字已经被检验到合理范围
  • 从一个低值开始的变量,然后每次交易增加一或小数值(如计数器)

每当看到代码中的算术运算时,请询问自己在上下文中是否存在对溢出或下溢的自然保护(也要考虑保存数字的变量的类型)。如果是这么做,添加一个 unchecked 块。

7. 编写气体优化的 for 循环

注意:截至前Solidity 0.8.22,编译器会自动执行此技巧,无需显式执行。这是一个气体优化的 for 循环:

for (uint256 i; i &lt; limit; ) {

    // 循环体

    unchecked {
        ++i;
    }
}

这里与常规 for 循环的两个区别在于,i++ 变成了 ++i(如上所述),并且它是不检查的,因为限制变量确保它不会溢出。

8. do-while 循环比 for 循环便宜

如果你想推动优化,即使创建稍微不寻常的代码,Solidity 的 do-while 循环比 for 循环的气体效率更高,即使你为循环不执行的情况增加了 if 条件检查。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

// times == 10 在两个测试中
contract Loop1 {
    function loop(uint256 times) public pure {
        for (uint256 i; i &lt; times;) {
            unchecked {
                ++i;
            }
        }
    }
}

contract Loop2 {
    function loop(uint256 times) public pure {
        if (times == 0) {
            return;
        }

        uint256 i;

        do {
            unchecked {
                ++i;
            }
        } while (i &lt; times);
    }
}

9. 避免不必要的变量类型转换,除非是整型,变量小于 uint256(包括布尔和地址)

使用 uint256 作为整数是更好的选择,除非需要更小的整数。这是因为 EVM 在使用时默认将更小的整数转换为 uint256。这一转换过程会增加额外的 gas 费用,因此从一开始就使用 uint256 更有效。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract Unnecessary_Typecasting {
    uint8 public num;

    function incrementNum() public {
        num += 1;
    }
}

// 消耗更少的 gas
contract NoTypecasting {
    uint256 public num;

    function incrementNumCheap() public {
        num += 1;
    }
}

10. 短路布尔运算

在 Solidity 中,当你评估布尔表达式(例如 ||(逻辑或)或 &&(逻辑与)运算符)时,在 || 的情况下,第二个表达式仅在第一个表达式评估为 false 时才被评估,而在 && 的情况下,第二个表达式仅在第一个表达式评估为 true 时才被评估。这被称为短路运算。例如,表达式 require(msg.sender == owner || msg.sender == manager) 的第一条表达式 msg.sender == owner 评估为 true 时将通过。第二条表达式 msg.sender == manager 将根本不被评估。然而,如果第一条表达式 msg.sender == owner 评估为 false,第二条表达式 msg.sender == manager 会被评估以确定整体表达式是 true 还是 false。在这里通过首先检查最可能通过的条件,我们可以避免检查第二个条件,从而在大多数成功调用中节省 gas。这对于表达式 require(msg.sender == owner && msg.sender == manager) 也是类似的。如果第一条表达式 msg.sender == owner 评估为 false,第二条表达式 msg.sender == manager 将不会被评估,因为整体表达式不可能为 true。要使整体语句为 true,表达式的两边都必须评估为 true。在这里,通过首先检查最可能失败的条件,我们可以避免检查第二个条件,从而在大多数调用回退中节省 gas。短路运算非常有用,建议将较便宜的表达式放在前面,因为更昂贵的表达式可能会被绕过。如果第二个表达式比第一个更重要,可能值得调换它们的顺序,以便便宜的表达式首先被评估。

11. 除非必要,否则不要将变量设为公共

公共存储变量具有同名的隐式公共函数。公共函数增加了跳转表的大小,并为读取相关变量增加了字节码。这使得合约变得更大。请记住,私有变量并不真正私有,使用 web3.js 提取变量值并不困难。特别是对于常量,更是如此,常量是针对人类而非智能合约设计的。

12. 偏好极大值用于优化器

Solidity 优化器主要关注于优化两个方面:

  1. 智能合约的部署成本。
  2. 智能合约内部函数的执行成本。

在选择优化器的运行参数时会存在权衡。较小的运行参数值优先考虑减小部署成本,从而导致较小的创建代码,但运行时代码可能未优化。这虽然减少了部署时的 gas 成本,但在执行期间可能不那样高效。相反,较大的运行参数值优先考虑执行成本。这会导致较大的创建代码,但是优化了运行时代码,从而减少执行时的 gas 成本。虽然这可能对部署Gas成本没有显著影响,但在执行时可能大大降低Gas成本。考虑到这种权衡,如果你的合约会频繁使用,建议为优化器使用更大的值。因为这在长期内会节省 gas 成本。

13. 被频繁使用的函数应具有最佳名称

EVM 使用跳转表进行函数调用,而较小的十六进制顺序的函数选择器会被优先排序在较大选择器之上。换句话说,如果在同一合约中存在两个函数选择器,例如 0x000071c30xa0712d68,在合约执行期间 0x000071c3 选择器的函数将最先被检查。因此,如果一个函数被频繁使用,则它必须有一个最佳名称。这项优化提高了其被首先排序的机会,从而减少了进一步检查的 gas 成本(尽管如果合同中函数数量超过四个,EVM 会对跳转表进行二进制搜索而不是线性搜索。)这还减少了 calldata 成本(如果函数有前导零,因为零字节的成本是4 gas,而非零字节的成本是16 gas)。下面是一个好的演示。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract FunctionWithLeadingZeros {
    uint256 public totalSupply;

    // 选择器 = 0xa0712d68
    function mint(uint256 amount) public {
        totalSupply += amount;
    }

    // 选择器 = 0x000071c3(这比上面的函数便宜)
    function mint_184E17(uint256 amount) public {
        totalSupply += amount;
    }
}

此外,我们还有一个很有用的工具,名为 Solidity Zero Finder,它用 Rust 构建,可以帮助开发者实现这个目的。它可以在这个 GitHub 仓库 中找到。

14. 位移比乘以或除以 2 的幂更便宜

在 Solidity 中,通过移位操作而不是使用乘法或除法运算符,对基数或幂是 2 的整数进行乘法或除法通常更节省气体。例如,下述两个表达式是等效的

10 * 2
10 &lt;&lt; 1 # 将 10 左移 1 位

它也是等效的

8 / 4
8 >> 2 # 将 8 右移 2 位

EVM 中的位移操作符 opcodes,如 shr(右移)和 shl(左移),耗时 3 gas,而乘法和除法操作(mul 和 div)各需 5 gas。大多数节省 gas 的操作也来自于 Solidity 对 shr 和 shl 操作的无溢出/下溢或除法检查。因此在使用这些运算符时重要的是要考虑溢出和下溢错误,以避免出现这些问题。

15. 在某些时候缓存 calldata 可能会更便宜

尽管 calldataload 指令是一个便宜的操作码,但如果你缓存 calldataload,Solidity 编译器有时会输出更便宜的代码。这并不是总是如此,因此应该测试这两种可能性。

contract LoopSum {
    function sumArr(uint256[] calldata arr) public pure returns (uint256 sum) {
        uint256 len = arr.length;
        for (uint256 i = 0; i &lt; len; ) {
            sum += arr[i];
            unchecked {
                ++i;
            }
        }
    }
}

16. 使用无分支算法替代条件语句和循环

早先章节中的 max 代码就是无分支算法的典型示例,也即避免了 JUMP 操作码,这让其相较于其它算术操作更加节省 gas。循环本身就包含跳转,因此也许你应该考虑 循环展开 以节省 gas。循环不需要展开到极限。例如,你可以以两个项目为单位执行循环,这样就能将跳转次数减半。这是一种非常极端的优化,但你应该意识到条件跳转和循环引入的操作码稍微额外的成本。

17. 内部函数仅被使用一次时可以内联以节省 gas

有内部函数是可以的,但它们会引入到字节码中的额外跳转标签。因此,在仅由一个函数使用这种情况下,将内部函数的逻辑内联到使用它的函数中更好。这样可以避免在函数执行期间跳转,从而节省一些 gas。

18. 如果数组或字符串长度超过 32 字节,则通过哈希比较数组和字符串的相等性

这是一个你很少使用的技巧,但查找数组或字符串的代价远不及哈希处理后比较它们的哈希值的代价。

19. 在计算幂和对数时使用查找表

如果你需要计算以某个分数为底数或幂的对数,预计算一个表可能会更可取,前提是底数或幂是固定的。考虑 Bancor 公式Uniswap V3 Tick Math 作为例子。

20. 预编译合约可能对某些乘法或内存操作有用

Ethereum 预编译合约 提供了主要用于密码学的操作,但如果你需要对大数进行模运算或复制大块内存,考虑使用预编译合约。请注意,这可能会使你的应用与某些二层网络不兼容。

21. n * n * n 可能比 n ** 3 更便宜

两个 MUL 操作码总费用为 10 gas,但 EXP 操作码费用为 10 gas + 50 * (指数字节大小)。


危险技巧

如果你在参加气体优化竞赛,这些不寻常的设计模式可能会有所帮助,但在生产中使用这些模式是高度不建议的,或者至少应该以极大的谨慎来实行。

1. 使用 gasprice() 或 msg.value 传递信息

向函数传递参数至少会增加 128 gas,因为 calldata 的每个零字节的费用为 4 gas。然而,你可以设置 gasprice 或 msg.value 免费地传递数字。当然,这在生产中是行不通的,因为 msg.value 需付出实际的以太坊,并且如果你的 gas 价格过低,交易不会通过,或者会浪费加密货币。

2. 如果测试允许,操纵环境变量如 coinbase() 或 block.number

这当然在生产中不会执行,但它可以作为一种侧信道来修改智能合约的行为。

3. 在关键时刻使用 gasleft() 分支决策

随着执行的进展,气体不断被耗尽,因此如果你想要在某个点后终止循环或者在执行的后面部分改变行为,可以使用 gasprice() 功能进行决策分支。gasleft() 的减量是“免费的”,因此这节省了 gas。

4. 使用 send() 转移以太,但不要检查成功与否

send 和 transfer 之间的区别在于,如果 transfer 失败则会回退,但 send 返回 false。然而,你可以忽略 send 的返回值,这将导致更少的操作码。忽略返回值是一种很不好的实践,编译器并没有阻止你这样做。在生产系统中,根本不应使用 send(),因为气体上限。

5. 使所有函数可支付

这是一种有争议的优化,因为其可能导致交易中意外的状态变化,并且不会节省太多 gas。但在气体竞赛的背景下,所有函数可支付避免了检查 msg.value 非零所增加的额外操作码。正如前面所提到的,将构造函数或管理函数设置为可支付是一种合理的方法,以节省 gas,因为部署者和管理者显然知道自己在做什么,并且能够比发送以太采取更具破坏性的操作。

6. 外部库跳转

Solidity 通常使用 4 字节和一个跳转表来确定使用哪个函数。然而,可以(极不安全地!)将跳转目标简单地作为 calldata 参数传递,从而将“函数选择器”减少到一个字节,完全避免跳转表。更多信息可以在这里找到 tweet

7. 将字节码附加到合约的末尾以创建高度优化的子例程

一些计算密集型算法,比如哈希函数,最好直接用原始字节码编写,而不是用 Solidity,甚至是 Yul。例如,Tornado Cash 将 MiMC 哈希函数写作单独的智能合约,直接用原始字节码编写。通过将字节码附加到实际合约中并在其间跳转,可以避免另外一个智能合约的额外 2600 或 100 gas(冷或热访问)费用。这是一个 使用 Huff 的证明概念


过时的技巧

1. external 比 public 更便宜

如果函数不能在合约内部调用,你仍然应该偏好使用 external 修饰符以提高清晰度,但这并不会对气体节省产生任何影响。

2. != 0 比 > 0 更便宜

大约在 solidity 0.8.12 的时候,这不再成立。如果你被迫使用旧版本,你仍然可以对它进行基准测试。


不良实践

有几个常见的错误会导致更高的 gas 成本。由于空间限制,我们已将此列表发布在另一篇文章中。

与 RareSkills 一起学习更多

学习总是更有效率,当你被一个有动力的社区包围,并且在经验丰富的教师指导下进行学习。此材料是我们高级 solidity bootcamp 的一部分。如果你想在业界领袖的指导下与其他 solidity 专业人士一起练习气体优化,请查看此计划!原始发布日期为 2023 年 9 月 7 日

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

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/