本文深入研究了以太坊中瞬态存储(Transient Storage,EIP-1153)的实际应用,分析了其在EVM数据管理中的作用、优势与局限性,并探讨了其在智能合约安全和效率方面的潜在影响。通过对链上合约的分析,揭示了瞬态存储在重入保护、跨链交互、状态验证等方面的应用,并量化了其与传统存储相比在gas消耗上的显著优势。
研究
Alex Zhang 和 Michael Debono
随着以太坊中瞬态存储的最新引入,以太坊虚拟机 (EVM) 中状态管理的格局再次演变。这一最新进展促使我们在 Dedaub 重新审视 EVM 生态系统中数据的存储和访问方式,并分析新的瞬态存储在实际应用中的使用方式。
需要注意的是,即使瞬态存储已正确集成到 EVM 中,transient
修饰符在 Solidity 中仍然不可用。因此,所有瞬态存储的使用都直接来自使用内联汇编的 TSTORE
和 TLOAD
操作码,这意味着使用尚未普及,并且可能存在更高的漏洞风险。
📢 实际上,截至 2024 年 10 月 9 日,随着 solc 0.8.28 的引入,已完全支持瞬态存储!这不会使本文中的任何内容失效,但请考虑本文是在以太坊区块号 20129223 时编写的。
在这篇全面的博客文章中,我们将探讨每种存储类型的优势和局限性。我们将讨论它们所有合适的用例,并Exam如何将瞬态存储的引入纳入更广泛的 EVM 数据管理生态系统中。如果你不需要复习 EVM 如何管理状态,请随时跳到 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
开始按定义的顺序存储。
flags
数组占据整个第一个槽。每个 bool
仅占用 1 个字节来存储,这意味着整个数组总共占用 8 个字节。uint160 time
存储在第二个槽中。即使它仅占用 20 个字节来存储,这意味着它可以放入第一个槽的剩余空间中,但它必须从第二个槽开始,因为第一个槽正在存储一个数组。string title
占据整个第三个槽,因为它是动态数据类型。该槽存储字符串的长度,并且字符串的实际字符应从 keccak256(2)
开始存储。data
结构体占用 2 个槽。结构体的第一个槽打包了 x
和 y
uint128
值,因为它们每个仅占用 16 个字节。然后,结构体的第二个槽存储了动态 bytes
值。keccak(pad(key) . uint256(5))
或 keccak(pad(key) . uint256(6))
。这是一个可视化存储的图:
如果 title
或 z
变量包含长度超过 31 字节的数据,则它们将改为存储在 keccak(s)
,如箭头所示。映射值按照上面定义的哈希键规则存储。
最后,存储变量也可以声明为 immutable
或 constant
。这些变量在合约的运行时不会改变,从而节省了 gas 费用,因为可以优化掉它们的计算。constant
变量在编译时定义,并且 Solidity 编译器将在编译期间将它们替换为定义的 值。另一方面,immutable
变量仍然可以在合约的构造期间定义。此时,代码将自动将对该值的所有引用替换为已定义的值。
与存储不同,内存不会在事务之间持久存在,并且所有内存值都在调用结束时被丢弃。由于内存读取具有 32 字节的固定大小,因此它将每个新值与自己的块对齐。因此,当 uint8[16] nums
存储在存储中时可能只有一个 32 字节的字,但在内存中将占用十六个 32 字节的字。无论如何定义,相同的拆分也会发生在结构体上。
对于像字节或字符串这样的数据类型,它们的变量需要分别使用 memory
或 storage
关键字来区分内存指针或存储指针。
映射和动态数组不存在于内存中,因为不断调整内存大小非常低效且昂贵。虽然可以使用 new <type>[](size)
分配具有固定大小的数组,但是你无法像使用 .push
和 .pop
对存储数组那样编辑这些数组的大小。
最后,内存优化非常重要,因为内存的 gas 成本随着内存的扩展而呈二次方增长,而不是线性增长。
与内存一样,栈数据仅存在于当前执行中。栈非常简单,只是一个 32 字节元素的列表,这些元素一个接一个地顺序存储。它使用 POP
、PUSH
、DUP
和 SWAP
指令进行修改,就像标准可执行文件中的栈一样。目前,栈最多只能存储 1024 个值。
大多数实际计算都在栈上完成。例如,算术操作码(如 ADD
或 MUL
)从栈中弹出两个值,然后将二进制运算的结果推送到栈上。
Calldata 与内存和栈数据类似,因为它仅存在于一个函数调用的上下文中。与内存一样,所有值也必须填充到 32 字节。但是,与在合约交互期间分配的内存不同,calldata 存储从外部源(如 EOA 或另一个智能合约)传入的只读参数。重要的是要注意,如果你想编辑从 calldata 传入的值,你必须先将它们复制到内存中。
Calldata 与事务期间的其余数据一起传入,因此必须根据要调用的函数的指定 ABI 正确打包。
瞬态存储是 EVM 的一项相当新的补充,Solidity 仅从 2024 年 开始支持操作码,预计不久的将来会实现正确的语言实现。它旨在用作在整个事务上下文中存在的有效键值映射,并且其操作码为 TSTORE
和 TLOAD
。它始终占用 100 gas,使其比常规存储更具有 gas 效率。
瞬态存储的特殊之处在于它可以持久存在于调用上下文中。这非常适合诸如重入保护之类的场景,这些场景可以在瞬态存储中设置标志,然后检查是否已在整个事务的上下文中设置该标志。然后,在整个事务结束时,保护将被完全清除,并且可以在将来的事务中像往常一样使用。
尽管瞬态存储具有瞬态性质,但必须注意的是,此存储仍然是以太坊状态的一部分。因此,它必须遵守与常规存储类似的规则和约束。例如,在禁止状态修改的 STATICCALL
上下文中,无法更改瞬态存储,这意味着仅允许使用 TLOAD
操作码,而不允许使用 TSTORE
。
由于瞬态存储是一项相对较新的功能,因此我们能够全面检查截至以太坊区块号 20129223 为止的所有用例。我们发现,在包含或具有包含 TSTORE
或 TLOAD
操作码的库的约 250 个已部署合约中,有约 180 个唯一的源文件,这意味着这些已部署合约中有超过 60 个是跨链部署的副本。
以下是这些约 190 个合约中瞬态存储使用情况的记录分布:
在使用此功能的链上大约 190 个唯一合约中,我们能够将它们区分为 6 个通用类别:
modifier ReentrancyGuard {
assembly {
// 如果已设置保护,则存在重入,因此还原
if tload(0) { revert(0, 0) }
// 否则,设置保护
tstore(0, 1)
}
_;
// 解锁保护,使模式可组合。
// 函数退出后,即使在同一事务中也可以再次调用它。
assembly {
tstore(0, 0)
}
}
// 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");
}
// ...
}
tstore
作为哈希映射来跟踪和管理事务上下文中的合格接收者。瞬态存储的引入标志着 EVM 数据管理能力的重大发展。我们在 Dedaub 的分析表明,虽然它仍处于采用的早期阶段,但瞬态存储已经产生了显着的影响,尤其是在智能合约安全性和效率方面。
瞬态存储的引入标志着 EVM 数据管理能力的重大发展。我们在 Dedaub 的分析表明,虽然它仍处于采用的早期阶段,但瞬态存储已经产生了显着的影响,尤其是在智能合约安全性和效率方面。
我们对瞬态存储使用情况的分析的主要结论包括:
在对瞬态存储使用情况的分析中,我们还评估了其与常规存储相比的 gas 效率。为此,我们收集了分析的每个合约的最后 100 个事务。对于每个事务,我们都获得了其执行跟踪,并使用 Python 脚本通过在相同条件下(包括冷加载惩罚和其他存储规则)将 TSTORE
操作替换为 SSTORE
来模拟 gas 成本。
结果令人印象深刻:在所有用例中,与常规存储操作相比,使用瞬态存储平均可节省 91.59% 的 gas。在下面,你可以找到一个更详细的图表,其中显示了每个类别的 gas 节省量。有趣的是,在专门功能的情况下,记录了大约 98.7% 的 gas 节省量。这是因为上面提到的 空投合约,在这种情况下,内存可能是一个更充分的比较。
随着以太坊生态系统的不断发展,我们希望看到更多样化和更复杂的瞬态存储用途出现。它的独特属性——在事务内的内部调用中持久存在,同时比常规存储更具有 gas 效率——为优化智能合约设计和执行开辟了新的可能性。
下面,我们发布了用于上述帖子的数据集和脚本。
- 原文链接: dedaub.com/blog/transien...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!