以太坊中的 13 个 Gas 模型不一致问题
这篇文章总结了以太坊 gas 模型中的 13 个不一致之处。
所有符号均来自以太坊黄皮书(特别是附录 G.费用表)。
1. 创建账户时,外部交易不会产生新账户费用( G_ { newaccount } G newaccount ) ,但内部交易会产生
例子
假设你想让合约C
将ETH转移到一个尚不存在的全新账户A
- 如果
C
直接转账,内部新建账户路径收取25,000 gas ( G_ { newaccount } G newaccount ) 。 - 替代方案:首先从 EOA 向
A
发送一笔外部交易,费用为 1 wei。这笔交易的Gas 成本为 21,000(标准基数)。之后,A
就存在了。现在C
向A
转账不再产生新账户费用(节省约 4,000 Gas )。
结论
如果你依靠合约来创建新账户,则需要支付 25,000 gas。
但是如果你先发送一个外部交易(21,000 gas),然后让合约发送资金,你实际上可以节省~4,000 gas 。
原因
外部和内部创建路径使用不同的收费挂钩;只有合约调用才会触发明确的新账户费用。
2. 预编译调用有时会跳过交易输入字节费用
例子
调用预编译合约(例如ECRECOVER
)可能会跳过交易输入字节的计费。通常情况下,每个输入字节的Gas 费用为 4(零)或16(非零) 。以下是两个真实的交易示例:
* 1. 交易0x6b01使用 24,276 gas 调用ECRECOVER
,其中 276 gas 用于输入字节。
* 2. 交易0x1fb0使用 24,000 gas 调用ECRECOVER
,其中 0 gas 用于输入字节。
如果你阅读执行客户端的源代码,你会发现第二个事务也可以执行ECRECOVER
,而无需对输入字节进行计费。客户端会用零数据填充输入以匹配预期的大小。
补充说明
我想你足够聪明,会注意到我提到的两笔交易都是外部交易。
那么他们为什么要调用预编译合约呢?
我猜部分原因是一些浏览器错误地将预编译地址(0x01-0x0A)标记为“刻录地址”,这进一步使用户感到困惑(请参见下面的快照)。
此外,在这些特殊地址(0x01–0x0A)中部署预编译地址是一个失败的设计。
有时,人们只是想直接呼叫这些特殊地址。
原因
预编译合约的地址设计不良以及区块浏览器的误导,导致混乱和错误标记。
3. 即使从未访问过,访问列表条目也会收费(即G_{accesslistaddress} G a c c e s s l i s t a d r e s s 、 G_{accessliststorage} G a c c e s s l i s t s t o r a g e )
例子
EIP-2930 引入了访问列表,允许交易指定其想要访问的地址和存储槽。然而,交易可以将地址和存储槽添加到其访问列表中,但永远不会触及它们。
例如,事务0x0dd0c设置了访问列表,但由于地址原因从未访问指定的插槽。
原因
该协议收取包含费用以简化执行,无论条目是否被使用。如果你相信你的用户能够提供正确的输入,那你还不如相信泰勒·斯威夫特是你的妻子。
4. 自转移仍需转移气体
例子
账户 A 将ETH发送给自己。
余额没有变化,但费用仍然包含价值转移所需的9,000 gas ( G_{ callvalue } G callvalue ) 。根据@vbuterin的这篇帖子 。
两次账户写入(一次余额编辑 CALL 通常需要花费 9000 gas)
为什么一个账户写入操作仍然需要花费9000 gas呢?其实,如果你看过执行客户端的源码,就会发现,当 from 地址和 to 地址相同时,客户端不会做任何操作。
当交易为自我转账或使用CALLCODE转移价值时,可能会发生上述情况。
原因
无论转移是否为无操作,都会触发执行费用。
5. 调用数据与合约字节码磁盘定价不匹配
例子
- Tx calldata: 16 gas/字节(非零)或4 gas/字节(零) 。
- 合约字节码: 200 gas/字节。
两者都占用磁盘空间,但定价却不一致。这让我很困惑,因为交易调用数据比合约字节码更便宜,因为它应该考虑实际的磁盘使用量和网络开销。
原因
Gas 计划将“调用数据”和“代码存放”分开,而不将它们与实际磁盘使用情况对齐。
6. 撤销的交易将按写入磁盘的方式收费
例子
恢复的交易会修改内存中的状态,但不会保留任何更改,但仍会收取写入费用,以下 gas 费用会受到影响:
- 25,000 gas (新账户, G_ { newaccount } G newaccount )
- 9,000 gas (价值转移, G_ { callvalue } G callvalue )
- 2,100 gas (冷槽, G_ { coldslot } G冷槽)
- 200 gas (代码存款, G_ { codedeposit } G代码存款)
实际内存成本约为100 gas 。
原因
执行期间会收取 Gas 费用;之后的回滚会取消状态更改,但不会取消费用。具体实现会谨慎收费,以防止 DoS 攻击。
7. 单笔交易中的多笔ETH转账被误认为是冷转账
例子
假设合约在单笔交易中多次将ETH发送到不同的账户。
- 第一次转账正确产生了G_{callvalue} G call value ( 9,000 gas )写入账户余额。
- 同一交易中后续向其他账户的转账应收取热访问费( 100 gas + 4,500 gas ),但有时仍按冷访问费计费( 9,000 gas )。
原因
对于单笔交易中的多次价值转移,冷/暖访问簿记并不会持续更新。
8. 矿工/验证者奖励或提现写入不收取费用
例子
协议级余额更新(例如奖励、提款)会修改磁盘上的状态,但消耗0 gas 。
原因
系统级簿记绕过了 gas 会计挂钩。
9. SSTORE 首次磁盘读取不收费(根据 EIP-2200)
例子
执行SSTORE
操作码时,它首先从磁盘(合约存储)读取当前值,然后再决定是否写入新值。根据EIP-2200规定,如果存储的值与现有值匹配,则不会进行磁盘写入,并且仅收取少量 Gas 费用。但是,初始磁盘读取本身不收取任何 Gas 费用——协议仅在值发生变化时对后续写入收取费用。
原因
EIP-2200 的逻辑侧重于对状态变化收费,但忽略了对总是首先发生的磁盘读取的收费。这意味着对存储槽的首次访问是免费的,即使是冷读。
10. 存储读取优化减少了 I/O,但 gas 保持不变
例子
以太坊客户端已采用扁平存储/快照优化(例如,Geth 的 快照加速结构),将状态组织为扁平键值存储,并允许直接磁盘读取,从而绕过了传统 Merkle-Patricia Trie (MPT) 所需的中间节点。此优化显著减少了冷存储读取的磁盘 I/O。例如,Geth 和其他客户端现在使用 SAS 或类似结构,但冷访问的 Gas 费用(2,600 / 2,100 / 2,400 / 1,900)保持不变。
原因
冷访问的 Gas 常数最初是针对 MPT 进行校准的,因为 MPT 需要遍历多个 trie 节点,因此磁盘读取成本更高。使用 SAS 后,实际磁盘资源消耗要低得多,但协议尚未更新相应的 Gas 费用。
减轻
当客户端切换到 SAS 或类似的优化存储后端时,重新校准气体常数以反映减少的磁盘 I/O。
11. SLOAD 与 MLOAD 定价不匹配
例子
SLOAD
(温暖)→ 100 气体MLOAD
→ 3 气体
两者都是内存读取,但是价格差别很大。
原因
状态和内存操作之间的遗留区别;优化模糊了实际成本差距。
12. 内部交易有时无需 Gas 即可更新账户
例子
当磁盘中的账户更新不收取 Gas 费用时。具体来说,这个问题出现在用户向合约 A 发送外部交易,合约 A 又对合约 B 进行内部调用的场景中。如果合约 B 修改了其存储中的某个 slot,则必须在磁盘上更新合约 B 账户中对应的存储根。然而,由于账户 B 的此次更新不收取 Gas 费用,导致不一致。
原因
出现此漏洞的原因是,合约 B 的存储 trie 修改不会因更新其账户状态而产生额外的 gas 费用。这是因为即使执行了磁盘写入操作,协议也不会对由内部交易触发的账户状态更新收取费用。
13. EXT* 操作码定价太粗略
例子EXTCODESIZE
可能读取比BALANCE
更多数据,但两者都收取相同的冷账户费用( 2,600 gas )。
原因
操作码定价桶很粗略,并且忽略了可变的工作。
结束语
这个问题来自我的论文如下,我通过此链接分享它。
何哲、李哲、罗建、罗锋、段建、李建…… & 张晓燕 (2025年2月)。Auspex:揭示区块链交易费机制的不一致性漏洞。刊于第23届USENIX文件与存储技术会议论文集。
如果您能引用我的论文,我将非常高兴。
所有这些都凸显了全面审查和调整以太坊协议内 Gas 定价机制的必要性。通过解决这些不一致问题,我们可以确保一个更高效、更公平的 Gas 市场,准确反映各种操作的底层资源成本。