以太坊实战瞬态存储:EIP-1153影响研究

  • Dedaub
  • 发布于 2024-10-23 17:47
  • 阅读 9

本文深入研究了以太坊中瞬态存储(Transient Storage,EIP-1153)的实际应用,分析了其在EVM数据管理中的作用、优势与局限性,并探讨了其在智能合约安全和效率方面的潜在影响。通过对链上合约的分析,揭示了瞬态存储在重入保护、跨链交互、状态验证等方面的应用,并量化了其与传统存储相比在gas消耗上的显著优势。

研究

Alex Zhang 和 Michael Debono

瞬态存储实战:EIP-1153 的影响研究

随着以太坊中瞬态存储的最新引入,以太坊虚拟机 (EVM) 中状态管理的格局再次演变。这一最新进展促使我们在 Dedaub 重新审视 EVM 生态系统中数据的存储和访问方式,并分析新的瞬态存储在实际应用中的使用方式。

需要注意的是,即使瞬态存储已正确集成到 EVM 中,transient 修饰符在 Solidity 中仍然不可用。因此,所有瞬态存储的使用都直接来自使用内联汇编的 TSTORETLOAD 操作码,这意味着使用尚未普及,并且可能存在更高的漏洞风险。

📢 实际上,截至 2024 年 10 月 9 日,随着 solc 0.8.28 的引入,已完全支持瞬态存储!这不会使本文中的任何内容失效,但请考虑本文是在以太坊区块号 20129223 时编写的。

在这篇全面的博客文章中,我们将探讨每种存储类型的优势和局限性。我们将讨论它们所有合适的用例,并Exam如何将瞬态存储的引入纳入更广泛的 EVM 数据管理生态系统中。如果你不需要复习 EVM 如何管理状态,请随时跳到 EIP-1153 影响分析 部分。

EIP-1153 | 数据存储和访问快速回顾

存储

以太坊中的存储是指合约持有的持久存储。此存储分为 32 字节的槽,每个槽都有自己的地址,范围从 0 到 2256 – 1。总的来说,这意味着一个合约最多可以存储 2261 字节。

当然,EVM 不会同时跟踪所有字节。相反,它更像是一个映射——如果需要使用特定的存储槽,则会像映射一样加载它,其中键是其索引,值是正在存储或访问的 32 字节。

从槽 0 开始,(Solidity)将尝试尽可能紧凑地存储静态大小的值,只有当值无法放入剩余空间时才移动到下一个槽。结构体和固定大小的数组也总是从一个新槽开始,并且任何后续项目也将从一个新槽开始,但是它们的值仍然是紧密打包的。

以下是 Solidity 文档中声明的规则:

  • 存储槽中的第一个项目以低位对齐方式存储,这意味着数据以大端形式存储。
  • 值类型仅使用存储它们所需的字节数。
  • 如果值类型不适合存储槽的剩余部分,则它将被存储在下一个存储槽中。
  • 结构体和数组数据总是从一个新槽开始,并且它们的项目根据这些规则紧密地打包。
  • 结构体或数组数据后面的项目总是从一个新的存储槽开始。

但是,对于映射和动态大小的数组,无法保证它们将占用多少空间,因此它们不能与其余的固定大小的值一起存储。

对于动态数组,它们本应占用的槽被数组的长度所代替。然后,数组的其余部分像固定大小的数组一样存储,从槽 keccak256(s) 开始,其中 s 是数组本应占用的原始槽。数组的动态数组以递归方式遵循此模式,这意味着 arr[0][0] 将位于 keccak256(keccak256(s)),其中 s 是存储原始数组的槽。

对于映射,该槽保持为 0,并且每个键值对都存储在 keccak256(pad(key) . s),其中 s 是映射的原始数据槽,. 是连接,并且如果键是值类型,则将其填充为 32 字节,但如果它是字符串和字节数组则不填充。此地址存储相应键的值,遵循与其他存储类型相同的规则。

例如,让我们看一个示例合约 Storage.sol 并查看其存储:

contract Storage {
    struct SomeData {
        uint128 x;
        uint128 y;
        bytes z;
    }

    bool[8] flags;
    uint160 time;

    string title;
    SomeData data;
    mapping(address => uint256) balances;
    mapping(address => SomeData) userDatas;

    // ...
}

可以使用来自 foundry 的命令 forge inspect Storage storage --pretty 来查看内部布局:

| Name      | Type                                        | Slot | Offset | Bytes | Contract                |
|-----------|---------------------------------------------|------|--------|-------|-------------------------|
| flags     | bool[8]                                     | 0    | 0      | 32    | src/Storage.sol:Storage |
| time      | uint160                                     | 1    | 0      | 20    | src/Storage.sol:Storage |
| title     | string                                      | 2    | 0      | 32    | src/Storage.sol:Storage |
| data      | struct Storage.SomeData                     | 3    | 0      | 64    | src/Storage.sol:Storage |
| balances  | mapping(address => uint256)                 | 5    | 0      | 32    | src/Storage.sol:Storage |
| userDatas | mapping(address => struct Storage.SomeData) | 6    | 0      | 32    | src/Storage.sol:Storage |

所有定义的值都从槽 0 开始按定义的顺序存储。

  1. 首先,flags 数组占据整个第一个槽。每个 bool 仅占用 1 个字节来存储,这意味着整个数组总共占用 8 个字节。
  2. uint160 time 存储在第二个槽中。即使它仅占用 20 个字节来存储,这意味着它可以放入第一个槽的剩余空间中,但它必须从第二个槽开始,因为第一个槽正在存储一个数组。
  3. string title 占据整个第三个槽,因为它是动态数据类型。该槽存储字符串的长度,并且字符串的实际字符应从 keccak256(2) 开始存储。
  4. 接下来,整个 data 结构体占用 2 个槽。结构体的第一个槽打包了 xy uint128 值,因为它们每个仅占用 16 个字节。然后,结构体的第二个槽存储了动态 bytes 值。
  5. 最后,有两个映射值,每个值都占用一个空槽来保留它们的映射。实际的映射值将分别存储在 keccak(pad(key) . uint256(5))keccak(pad(key) . uint256(6))

这是一个可视化存储的图:

EIP-1153

如果 titlez 变量包含长度超过 31 字节的数据,则它们将改为存储在 keccak(s),如箭头所示。映射值按照上面定义的哈希键规则存储。

最后,存储变量也可以声明为 immutableconstant。这些变量在合约的运行时不会改变,从而节省了 gas 费用,因为可以优化掉它们的计算。constant 变量在编译时定义,并且 Solidity 编译器将在编译期间将它们替换为定义的 值。另一方面,immutable 变量仍然可以在合约的构造期间定义。此时,代码将自动将对该值的所有引用替换为已定义的值。

内存

与存储不同,内存不会在事务之间持久存在,并且所有内存值都在调用结束时被丢弃。由于内存读取具有 32 字节的固定大小,因此它将每个新值与自己的块对齐。因此,当 uint8[16] nums 存储在存储中时可能只有一个 32 字节的字,但在内存中将占用十六个 32 字节的字。无论如何定义,相同的拆分也会发生在结构体上。

对于像字节或字符串这样的数据类型,它们的变量需要分别使用 memorystorage 关键字来区分内存指针或存储指针。

映射和动态数组不存在于内存中,因为不断调整内存大小非常低效且昂贵。虽然可以使用 new <type>[](size) 分配具有固定大小的数组,但是你无法像使用 .push.pop 对存储数组那样编辑这些数组的大小。

最后,内存优化非常重要,因为内存的 gas 成本随着内存的扩展而呈二次方增长,而不是线性增长。

与内存一样,栈数据仅存在于当前执行中。栈非常简单,只是一个 32 字节元素的列表,这些元素一个接一个地顺序存储。它使用 POPPUSHDUPSWAP 指令进行修改,就像标准可执行文件中的栈一样。目前,栈最多只能存储 1024 个值。

大多数实际计算都在栈上完成。例如,算术操作码(如 ADDMUL)从栈中弹出两个值,然后将二进制运算的结果推送到栈上。

Calldata

Calldata 与内存和栈数据类似,因为它仅存在于一个函数调用的上下文中。与内存一样,所有值也必须填充到 32 字节。但是,与在合约交互期间分配的内存不同,calldata 存储从外部源(如 EOA 或另一个智能合约)传入的只读参数。重要的是要注意,如果你想编辑从 calldata 传入的值,你必须先将它们复制到内存中。

Calldata 与事务期间的其余数据一起传入,因此必须根据要调用的函数的指定 ABI 正确打包。

瞬态存储

瞬态存储是 EVM 的一项相当新的补充,Solidity 仅从 2024 年 开始支持操作码,预计不久的将来会实现正确的语言实现。它旨在用作在整个事务上下文中存在的有效键值映射,并且其操作码为 TSTORETLOAD。它始终占用 100 gas,使其比常规存储更具有 gas 效率。

瞬态存储的特殊之处在于它可以持久存在于调用上下文中。这非常适合诸如重入保护之类的场景,这些场景可以在瞬态存储中设置标志,然后检查是否已在整个事务的上下文中设置该标志。然后,在整个事务结束时,保护将被完全清除,并且可以在将来的事务中像往常一样使用。

尽管瞬态存储具有瞬态性质,但必须注意的是,此存储仍然是以太坊状态的一部分。因此,它必须遵守与常规存储类似的规则和约束。例如,在禁止状态修改的 STATICCALL 上下文中,无法更改瞬态存储,这意味着仅允许使用 TLOAD 操作码,而不允许使用 TSTORE

EIP-1153 影响分析

由于瞬态存储是一项相对较新的功能,因此我们能够全面检查截至以太坊区块号 20129223 为止的所有用例。我们发现,在包含或具有包含 TSTORETLOAD 操作码的库的约 250 个已部署合约中,有约 180 个唯一的源文件,这意味着这些已部署合约中有超过 60 个是跨链部署的副本。

以下是这些约 190 个合约中瞬态存储使用情况的记录分布:

EIP-1153

在使用此功能的链上大约 190 个唯一合约中,我们能够将它们区分为 6 个通用类别:

  1. 首先,超过 50% 的瞬态存储使用是在重入保护上。这是有道理的,因为重入保护是瞬态存储的完美用例,并且也很容易实现,一个简单的可能如下所示:
modifier ReentrancyGuard {
    assembly {
            // 如果已设置保护,则存在重入,因此还原
        if tload(0) { revert(0, 0) }
        // 否则,设置保护
        tstore(0, 1)
    }
    _;
    // 解锁保护,使模式可组合。
    // 函数退出后,即使在同一事务中也可以再次调用它。
    assembly {
        tstore(0, 0)
    }
}
  1. 另一方面,只有 3.6% 的合约将此模式用作进入锁定,从而锁定事务之间的合约状态,以确保某些函数只能在调用其他函数后才能调用。这是一个简短的示例。
// keccak256("entrancy.slot")
uint256 constant ENTRANCY_SLOT = 0x53/*...*/15;

function enter() {
    uint256 entrancy = 0;
    assembly {
        entrancy := tload(ENTRANCY_SLOT)
    }
    if (entrancy != 0) {
                revert("Already entered");
    }

    entrancy = 1;
    assembly {
        tstore(ENTRANCY_SLOT, entrancy)
    }
}

function withdraw() {
    uint256 entrancy = 0;
    assembly {
        entrancy := tload(ENTRANCY_SLOT)
    }

    if (entrancy == 0) {
        revert("Not entered yet");
    }

    // ...
}
  1. 接下来,大约 6% 的合约使用瞬态存储来保留回调函数或跨链事务的合约上下文。这主要是在桥合约上,例如这里的这个
  2. 8.3% 的合约使用瞬态存储来保存合约状态的临时副本,以验证某些操作是否已授权。例如,OpenSea 的此合约 暂时存储授权的运算符、特定代币和与这些代币相关的金额,以验证所有转移是否按应有的方式进行。
  3. 略低于 9% 的合约将瞬态存储用于其自身的特殊目的。例如,一个 空投合约 利用 tstore 作为哈希映射来跟踪和管理事务上下文中的合格接收者。
  4. 虽然 20% 的合约在字节码中没有瞬态存储操作码,但包含在引用库中使用了瞬态存储的函数。这些库大多数是 openzeppelin 内部结构,例如他们对 ERC1967 的实现(请参阅 StorageSlot)。

瞬态存储的引入标志着 EVM 数据管理能力的重大发展。我们在 Dedaub 的分析表明,虽然它仍处于采用的早期阶段,但瞬态存储已经产生了显着的影响,尤其是在智能合约安全性和效率方面。

瞬态存储的引入标志着 EVM 数据管理能力的重大发展。我们在 Dedaub 的分析表明,虽然它仍处于采用的早期阶段,但瞬态存储已经产生了显着的影响,尤其是在智能合约安全性和效率方面。

我们对瞬态存储使用情况的分析的主要结论包括:

  • 重入保护是当前的主要用例,占瞬态存储实现的 50% 以上。这突出了开发人员在使用瞬态存储进行事务内的跨函数状态管理方面看到的直接价值。
  • 除了安全性之外,创新的开发人员正在找到创造性的方法来利用瞬态存储来存储上下文信息和管理整个复杂事务的上下文。
  • 瞬态存储的采用虽然仍然有限,但显示出提高 gas 效率和简化某些智能合约模式的希望。

Gas 效率提升

在对瞬态存储使用情况的分析中,我们还评估了其与常规存储相比的 gas 效率。为此,我们收集了分析的每个合约的最后 100 个事务。对于每个事务,我们都获得了其执行跟踪,并使用 Python 脚本通过在相同条件下(包括冷加载惩罚和其他存储规则)将 TSTORE 操作替换为 SSTORE 来模拟 gas 成本。

结果令人印象深刻:在所有用例中,与常规存储操作相比,使用瞬态存储平均可节省 91.59% 的 gas。在下面,你可以找到一个更详细的图表,其中显示了每个类别的 gas 节省量。有趣的是,在专门功能的情况下,记录了大约 98.7% 的 gas 节省量。这是因为上面提到的 空投合约,在这种情况下,内存可能是一个更充分的比较。

结论

随着以太坊生态系统的不断发展,我们希望看到更多样化和更复杂的瞬态存储用途出现。它的独特属性——在事务内的内部调用中持久存在,同时比常规存储更具有 gas 效率——为优化智能合约设计和执行开辟了新的可能性。

下面,我们发布了用于上述帖子的数据集和脚本。

dump_transient_traces.zip

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

0 条评论

请先 登录 后评论
Dedaub
Dedaub
Security audits, static analysis, realtime threat monitoring