本文旨在探索 EVM 的 gas 机制,尤其是 GasToken 的 EVM gas 机制。首先,为了降低技术理解的难度,本文需要先给出一段介绍。如果你不想了解底层机制,可以直接跳到后文的 “具体实现细节” 一节开始阅读。
引言—— gas 的基础知识
以太坊使用了一种 gas 计量系统,主要是为了防止停机问题和重入攻击(reentry attack)。这个计量系统似乎是最简单,也是最健壮的(尽管还有其它计量系统,如 EOS 系统)。EVM 的每个操作码都有固定的 gas 消耗量,黄皮书中注明了不同指令的 gas 成本等级:零级(0 gas)、基础级(2 gas)、超低级(3 gas)、低级(5 gas)、高级(10 gas),以及规则更加复杂的特殊等级。
例如,在 EVM 堆栈上添加或删除操作需要花费 3 gas。其中一些操作码在某些硬分叉部署之后经过了重新定价,例如,calldata(EVM 中的只读内存区域 —— 总计的 4 种存储类型之一)已经从每字节 68 gas 的价格下调至每字节 18 gas。重新定价似乎是为了促进二层可扩展方案的实现,因为二层可扩展方案需要链上数据可用性。还有证据表明,操作码的原始定价并没有经过充分分析,依然存在定价不当的问题。另外,更改操作码的 gas 消耗量也会带来问题:
· 降低指令的 gas 价格可能会让重入攻击变得可行
· 提高指令的 gas 价格可能会致使调用失败,因为这会导致 gas 分配量不足以执行调用
一笔交易所使用的操作码会累计出一个总的 gas 使用量。gas 使用量与 gasPrice(以太坊交易中的用户设置字段,即该用户愿意为每单位的 gas 支付的 eth 价格)的乘积会转换成 Wei,也就是以太坊原生代币 ETH。更多关于交易的基础知识,可以参见这篇文章。
区块的 gas 上限
一般情况下,矿工都是依据最高价拍卖模型将交易打包到区块内的。备受期待的 EIP 1559 意图将这一动态转变为更有效的结构,另外还有交易费必须使用以太币支付所带来的副作用。然而,这篇文章不是专门介绍 EIP 1559 的,EIP 1559 这个主题本身就具有非常深远的影响。在这篇文章中,我们将聚焦于促成 GasToken 的 gas 机制/经济学。
每个区块都有一个相关的 gas 上限,目前(2020 年 11 月)是 1250 万 gas。因此,由于区块容量有限,形成了一个竞争激烈的 “区块空间” 市场。虽然验证时间占区块传播时间的比例低于 1%,但这个上限的存在还是保证了网络的安全性。将区块 gas 上限定得太高,节点很难赶在下一个区块挖出之前执行完区块中的所有交易(也有可能跳过这些交易 —— 具体参见验证者困境)。将区块 gas 上限定得太低,就会导致网络拥堵和缺乏实用性。关于这里的权衡关系,请参见这篇文章。
有趣的是,矿工可以使用节点 cli flag 来标记他们所期望的区块 gas 上限,但是修改 gas 上限(例如最近从 8M 上调至 12.5 M)似乎主要发生在 “社会层面(推特)” 上。正是因为矿工可以上调/下调每个区块 gas 上限的机制,让我们明白了下图为什么会出现峰值:
区块空间拍卖被认为在经济学/机制设计方面开辟了新的领域,因为传统拍卖理论是以免费投标的假设为前提的。以太坊交易并非如此,交易费率必须在一定的阈值之上,而且一旦交易被广播到点对点网络上,就不受控制了。
接下来进入正题
可以说,最有趣的操作码同时也是成本较高的操作码,如 SSTORE、 CREATE、 CREATE2 和 SELFDESTRUCT。这些操作码的共同点是,它们都涉及状态,因此也涉及硬盘读写(以太坊网络的节点通常使用固态硬盘)。这些操作码成本更高,因为它们会影响永久存储和全局状态树。
什么是 GasToken
GasToken 巧妙地利用了 gas 定价系统。它利用的是清理状态、清理存储插槽(storage slot)和删除带有自毁操作码的合约(这些操作都可以删减全局状态树)所收到的 gas 退款。这些操作都可以被认为具备负 gas 价格。
· 清理/自毁合约:- 24,000 gas
· 清理/删除存储:-15,000 gas
当 EVM 执行这类操作时,gas 退款是通过一个独立的交易退款计量器来计算的。gas 退款只会在交易结束时提供。另外,最高 gas 退款量是该交易所消耗 gas 量的一半。
理想情况是在网络 gas 价格较低时写入状态,并在 gas 价格较高时删除状态。由于以 Wei/ETH 为计价单位的总费用是 gas 使用量和 gas 价格的乘积,当 gas 价格较高时,减少 gas 使用量会导致总费用降低。
GasToken 的正统实现很好地体现了名称中的 “token(代币)”部分,因为它与 ERC-20 代币相似,并带有 approve 和 transferFrom操作码,可以称为多步骤交易的一部分。最初,GasToken 有两种变体,分别采用不同的设计:GST1 和 GST2。GST1 使用的是存储成本和退款机制,GST2 使用的是 CREATE 和自毁机制。这些变体采取不同的节约方案,具体取决于 gas 价格差值比(铸造代币和释放代币时的 gas 价格差值比)。由于 gas 价格率更高,GST2 更能节约 gas。
开采或 “铸造” GasToken 就是将其 写入存储/创建合约,而销毁或 “释放” GasToken 就是减少用户持有的 GasToken 数量并删除状态存储插槽。虽然正统的 GasToken 很流行,但是许多开发者选择克隆这一功能,并放到他们自己的系统合约中使用,从而减少成本和设计复杂性。
具体实现细节
GST1 —— 基于存储
从智能合约的层面来看,GST1 是什么样的?我们先来看一下 mint() 函数:
function mint(uint256 value) public {
uint256 storage_location_array = STORAGE_LOCATION_ARRAY; // can’t use constants inside assembly
if (value == 0) {
return;
}
// Read supply
uint256 supply;
assembly {
supply := sload(storage_location_array)
}
// Set memory locations in interval [l, r]
uint256 l = storage_location_array + supply + 1;
uint256 r = storage_location_array + supply + value;
assert(r >= l);
for (uint256 i = l; i <= r; i++) {
assembly {
sstore(i, 1)
}
}
// Write updated supply & balance
assembly {
sstore(storage_location_array, add(supply, value))
}
s_balances[msg.sender] += value;
}
简单来说,我们使用一个存储起点常量来标记 EVM 存储的开始,而且这个常量还包括我们已经写入多少个插槽的值。如果你想了解更多关于 EVM 中永久存储布局的内容,请阅读这篇文章。通过第 12 和第 13 行的代码,我们可以计算出新的待写入插槽范围,并在第 17 行的 for 循环中使用 SSTORE 操作码来将数据写入这些插槽,存储数值 1(这个值可以替换成任何非零值)。然后,我们在第 22 和 24 行代码处更新已写入数据的插槽数量和余额。
自由函数更有趣一点,具备以下功能:freeFromUpTo(uint value)、 freeFrom(uint value)、 freeUpTo(uint value) 和 free(uint value)。这类函数在下文统称为 free*() 函数,调用内部函数 freeStorage():
function freeStorage(uint256 value) internal {
uint256 storage_location_array = STORAGE_LOCATION_ARRAY; // can’t use constants inside assembly
// Read supply
uint256 supply;
assembly {
supply := sload(storage_location_array)
}
// Clear memory locations in interval [l, r]
uint256 l = storage_location_array + supply – value + 1;
uint256 r = storage_location_array + supply;
for (uint256 i = l; i <= r; i++) {
assembly {
sstore(i, 0)
}
}
// Write updated supply
assembly {
sstore(storage_location_array, sub(supply, value))
}
}
如你所见,该函数与上文讨论的 mint() 函数几乎相同,主要的区别在于第 13 行代码,将值 0 写入存储会导致 EVM 释放存储插槽。这行代码会触发 gas 退款,让 gas 退款计数器增加 15000。更新 ERC-20 类型余额的任务也由 free*() 函数承担。
GST2 —— 基于合约
与 mint() 函数等价的函数,在 GST2 合约里叫做 makeChild() ,它是一个内部函数,使用 EVM 来汇编创建一个简单的 “child” 合约,而且该合约只能用 “parent” 合约来摧毁:
function makeChild() internal returns (address addr) {
assembly {
// EVM assembler of runtime portion of child contract:
// ;; Pseudocode: if (msg.sender != 0x0000000000b3f879cb30fe243b4dfee438691c04) { throw; }
// ;; selfdestruct(msg.sender)
// PUSH15 0xb3f879cb30fe243b4dfee438691c04 ;; hardcoded address of this contract
// CALLER
// XOR
// PC
// JUMPI
// CALLER
// SELFDESTRUCT
// Or in binary: 6eb3f879cb30fe243b4dfee438691c043318585733ff
// Since the binary is so short (22 bytes), we can get away
// with a very simple initcode:
// PUSH22 0x6eb3f879cb30fe243b4dfee438691c043318585733ff
// PUSH1 0
// MSTORE ;; at this point, memory locations mem[10] through
// ;; mem[31] contain the runtime portion of the child
// ;; contract. all that’s left to do is to RETURN this
// ;; chunk of memory.
// PUSH1 22 ;; length
// PUSH1 10 ;; offset
// RETURN
// Or in binary: 756eb3f879cb30fe243b4dfee438691c043318585733ff6000526016600af3
// Almost done! All we have to do is put this short (31 bytes) blob into
// memory and call CREATE with the appropriate offsets.
let solidity_free_mem_ptr := mload(0x40)
mstore(solidity_free_mem_ptr, 0x00756eb3f879cb30fe243b4dfee438691c043318585733ff6000526016600af3)
addr := create(0, add(solidity_free_mem_ptr, 1), 31)
}
仔细研究这个汇编代码可以更好地理解 EVM。我个人的观点是,合约开发者在原则上不应该使用汇编,但也有例外,那就是在设计上要求最小化并要求极高效率的合约。这个合约,还有 EIP-1167,就是例子。
优化
第 4 行和第 5 行中展示的时 child 合约中的回调函数(fallback function)的伪代码 —— 为什么要用回调函数?因为我们希望 child 合约能尽可能简单,简单到只有一个函数。
从 PUSH15 开始:地址本来有 20 个字节,但在这里,我们想把 15 个字节推入这个栈(这是最优实现),因为我们使用了 vanity-address 风格的技巧,它会重复地哈希,直到找到符合需要的地址,所以前面 5 个字节都是 0。剩下还需要 5 个 0,作为默认的一部分填充进去,组成 32 个字节,也就是 EVM 里面 word 的大小。这里的优化是很重要的,因为用来创建 chile 合约所用的 gas 可以认为是整个 GasToken 方案的开销。
下一步, CALLER 把合约调用者的地址推入栈中。 XOR 会从栈中弹出两个物,然后把这两个值的按位异或运算结果推入栈中。如果这两个值相等,则栈顶为 0,反之则是一个非零的数字。 PC 在与此操作对应的增量出现之前从程序计数器处获得一个值,并推入栈中。 JUMPI ,一个条件跳转,从栈中取出栈顶的两个值,一个条件和一个目标,如果条件为真,就跳到目标,如果条件不为真,那就失败。
如果 JUMPI 的结果不是 JUMPDEST 操作码,EVM 就会回滚,这保证了调用者是 parent 合约(满足 != 条件)。失败的路径结束后,就把 parent 合约的地址推入栈中,当下一次 slefdestruct 操作执行时,弹出栈顶的 word,作为 gas 退款的目标。