10

引介 | GasToken:我为何不再担心 gas 价格飙升(上)

 3 years ago
source link: https://ethfans.org/posts/gastoken-or-how-i-learned-to-stop-worrying-and-love-gas-price-surges
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

本文旨在探索 EVM 的 gas 机制,尤其是 GasToken 的 EVM gas 机制。首先,为了降低技术理解的难度,本文需要先给出一段介绍。如果你不想了解底层机制,可以直接跳到后文的 “具体实现细节” 一节开始阅读。

bEzIfyQ.jpg!mobile

- 要是某个表出现反转那就完蛋了 —— 但是 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 上限的机制,让我们明白了下图为什么会出现峰值:

7VRbuaY.jpg!mobile

- 上图显示了区块 gas 上限随时间流逝的变化情况。请注意,区块 gas 上限之所以会在 2016 年底大幅降低,是因为当时遭到了 DDoS 攻击。(来源: https://blog.ethereum.org/2016/09/22/ethereum-network-currently-undergoing-dos-attack/ ) -

区块空间拍卖被认为在经济学/机制设计方面开辟了新的领域,因为传统拍卖理论是以免费投标的假设为前提的。以太坊交易并非如此,交易费率必须在一定的阈值之上,而且一旦交易被广播到点对点网络上,就不受控制了。

接下来进入正题

可以说,最有趣的操作码同时也是成本较高的操作码,如 SSTORECREATECREATE2SELFDESTRUCT 。这些操作码的共同点是,它们都涉及状态,因此也涉及硬盘读写(以太坊网络的节点通常使用固态硬盘)。这些操作码成本更高,因为它们会影响永久存储和全局状态树。

什么是 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 代币相似,并带有 approvetransferFrom 操作码,可以称为多步骤交易的一部分。最初,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 退款的目标。

(未完)

原文链接: https://medium.com/coinmonks/gastoken-or-how-i-learned-to-stop-worrying-and-love-gas-price-surges-6aaee9fb0ba3

作者:Aodhgan Gleeson

翻译&校对:闵敏 & 阿剑


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK