一文了解 GasToken:我为何不再担心 gas 价格飙升(上)
本文旨在探索 EVM 的 gas 机制,尤其是 GasToken 的 EVM gas 机制。首先,为了降低技术理解的难度,本文需要先给出一段介绍。如果你不想了解底层机制,可以直接跳到后文的 “具体实现细节” 一节开始阅读。
- 要是某个表出现反转那就完蛋了 —— 但是 EVM 就不一样了 -
引言—— 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 上限的机制,让我们明白了下图为什么会出现峰值:
- 上图显示了区块 gas 上限随时间流逝的变化情况。请注意,区块 gas 上限之所以会在 2016 年底大幅降低,是因为当时遭到了 DDoS 攻击。(来源:https://blog.ethereum.org/2016/09/22/ethereum-network-currently-undergoing-dos-attack/) -
区块空间拍卖被认为在经济学/机制设计方面开辟了新的领域,因为传统拍卖理论是以免费投标的假设为前提的。以太坊交易并非如此,交易费率必须在一定的阈值之上,而且一旦交易被广播到点对点网络上,就不受控制了。
接下来进入正题
可以说,最有趣的操作码同时也是成本较高的操作码,如 、 、 和 。这些操作码的共同点是,它们都涉及状态,因此也涉及硬盘读写(以太坊网络的节点通常使用固态硬盘)。这些操作码成本更高,因为它们会影响永久存储和全局状态树。
什么是 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 代币相似,并带有 和 操作码,可以称为多步骤交易的一部分。最初,GasToken 有两种变体,分别采用不同的设计:GST1 和 GST2。GST1 使用的是存储成本和退款机制,GST2 使用的是 和自毁机制。这些变体采取不同的节约方案,具体取决于 gas 价格差值比(铸造代币和释放代币时的 gas 价格差值比)。由于 gas 价格率更高,GST2 更能节约 gas。
开采或 “铸造” GasToken 就是将其 写入存储/创建合约,而销毁或 “释放” GasToken 就是减少用户持有的 GasToken 数量并删除状态存储插槽。虽然正统的 GasToken 很流行,但是许多开发者选择克隆这一功能,并放到他们自己的系统合约中使用,从而减少成本和设计复杂性。
具体实现细节
GST1 —— 基于存储
从智能合约的层面来看,GST1 是什么样的?我们先来看一下 函数:
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 行代码处更新已写入数据的插槽数量和余额。
自由函数更有趣一点,具备以下功能:、 、 和 。这类函数在下文统称为 函数,调用内部函数 :
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 —— 基于合约
与 函数等价的函数,在 GST2 合约里叫做 ,它是一个内部函数,使用 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 合约能尽可能简单,简单到只有一个函数。
从 开始:地址本来有 20 个字节,但在这里,我们想把 15 个字节推入这个栈(这是最优实现),因为我们使用了 vanity-address 风格的技巧,它会重复地哈希,直到找到符合需要的地址,所以前面 5 个字节都是 0。剩下还需要 5 个 0,作为默认的一部分填充进去,组成 32 个字节,也就是 EVM 里面 word 的大小。这里的优化是很重要的,因为用来创建 chile 合约所用的 gas 可以认为是整个 GasToken 方案的开销。
下一步, 把合约调用者的地址推入栈中。 会从栈中弹出两个物,然后把这两个值的按位异或运算结果推入栈中。如果这两个值相等,则栈顶为 0,反之则是一个非零的数字。 在与此操作对应的增量出现之前从程序计数器处获得一个值,并推入栈中。 ,一个条件跳转,从栈中取出栈顶的两个值,一个条件和一个目标,如果条件为真,就跳到目标,如果条件不为真,那就失败。
如果 的结果不是 操作码,EVM 就会回滚,这保证了调用者是 parent 合约(满足 条件)。失败的路径结束后,就把 parent 合约的地址推入栈中,当下一次 操作执行时,弹出栈顶的 word,作为 gas 退款的目标。
(未完)
微信掃描關注公眾號,及時掌握新動向
2.本文版權歸屬原作所有,僅代表作者本人觀點,不代表比特範的觀點或立場
2.本文版權歸屬原作所有,僅代表作者本人觀點,不代表比特範的觀點或立場