引介

GasToken:我为何不再担心 gas 价格飙升

Ajian   |     |   520 次阅读

本文旨在探索 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/) -

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

接下来进入正题

可以说,最有趣的操作码同时也是成本较高的操作码,如 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 退款的目标。

free*() 函数调用下列 destroyChildren() 实现:

function destroyChildren(uint256 value) internal {
    uint256 tail = s_tail;
    // tail points to slot behind the last contract in the queue
    for (uint256 i = tail + 1; i <= tail + value; i++) {
        mk_contract_address(this, i).call();
    }

    s_tail = tail + value;
}

- 点击此处,查看源代码 -

此处,我们遍历 child 合约,并调用这些合约中的回调函数。正如 GST2 文档所指出的那样,发行代币时,合约必须找到 child 合约的创建地址(存储这些地址的成本会很高,因此我们会即时计算这些地址)。幸运的是,这是有可能做到的,因为使用 CREATE 生成的合约地址是根据地址/账户已创建的合约数量(nonce)计算得出的,具有确定性。这些合约地址都是在 mk_contract_address 函数中计算得出的,在调用时无需任何参数或值,调用回调函数,然后就像在对应 mint() 函数中硬编码的那样,gas 退款会发送至 parent 合约。

CHI GasToken

历时 3 年,CHI GasToken 终于上线。CHI 由去中心化交易所聚合器 1inch.exchange 开发,与传统的 GasToken 类似,但是铸币效率比后者高出 1%,释放代币的效率比后者高出 10%,而且采用新的 CREATE2 操作码。该操作码可以提前通过确定性方式来创建链上合约地址,主要用于反事实的 Layer-2 解决方案。

CREATE2 操作码采用 4 个堆栈参数:endowment、memory_start、memory_length 和盐值。生成地址等于 keccak256( 0xff ++ address ++ salt ++ keccak256(init_code))[12:],而非常见的将发送方地址和 nonce 进行哈希计算。由于盐值控制在用户手中,用户可以提前知道地址。

function mint(uint256 value) public {
    uint256 offset = totalMinted;
    assembly {
        mstore(0, 0x746d4946c0e9F43F4Dee607b0eF1fA1c3318585733ff6000526015600bf30000)

        for {let i := div(value, 32)} i {i := sub(i, 1)} {
            pop(create2(0, 0, 30, add(offset, 0))) pop(create2(0, 0, 30, add(offset, 1)))
            pop(create2(0, 0, 30, add(offset, 2))) pop(create2(0, 0, 30, add(offset, 3)))
            pop(create2(0, 0, 30, add(offset, 4))) pop(create2(0, 0, 30, add(offset, 5)))
            pop(create2(0, 0, 30, add(offset, 6))) pop(create2(0, 0, 30, add(offset, 7)))
            pop(create2(0, 0, 30, add(offset, 8))) pop(create2(0, 0, 30, add(offset, 9)))
            pop(create2(0, 0, 30, add(offset, 10))) pop(create2(0, 0, 30, add(offset, 11)))
            pop(create2(0, 0, 30, add(offset, 12))) pop(create2(0, 0, 30, add(offset, 13)))
            pop(create2(0, 0, 30, add(offset, 14))) pop(create2(0, 0, 30, add(offset, 15)))
            pop(create2(0, 0, 30, add(offset, 16))) pop(create2(0, 0, 30, add(offset, 17)))
            pop(create2(0, 0, 30, add(offset, 18))) pop(create2(0, 0, 30, add(offset, 19)))
            pop(create2(0, 0, 30, add(offset, 20))) pop(create2(0, 0, 30, add(offset, 21)))
            pop(create2(0, 0, 30, add(offset, 22))) pop(create2(0, 0, 30, add(offset, 23)))
            pop(create2(0, 0, 30, add(offset, 24))) pop(create2(0, 0, 30, add(offset, 25)))
            pop(create2(0, 0, 30, add(offset, 26))) pop(create2(0, 0, 30, add(offset, 27)))
            pop(create2(0, 0, 30, add(offset, 28))) pop(create2(0, 0, 30, add(offset, 29)))
            pop(create2(0, 0, 30, add(offset, 30))) pop(create2(0, 0, 30, add(offset, 31)))
            offset := add(offset, 32)
        }

        for {let i := and(value, 0x1F)} i {i := sub(i, 1)} {
            pop(create2(0, 0, 30, offset))
            offset := add(offset, 1)
        }
    }

    _mint(msg.sender, value);
    totalMinted = offset;
}

这里的一般流程是,将固定的 child 合约字节码存储到 memory 中(第 4 行代码),然后使用 for 循环反复调用 CREATE2,直到计算出对应的值为止。 CREATE2返回已部署 child 合约的地址,我们不关心这个地址,因此我们只是将这个地址从堆栈中弹出。偏移量计数器被用来计算 child 合约的数量,并将其永久存储在第 33 行代码中。

对应的 free*() 函数调用 _destoryChildren()

function _destroyChildren(uint256 value) internal {
    assembly {
        let i := sload(totalBurned_slot)
        let end := add(i, value)
        sstore(totalBurned_slot, end)

        let data := mload(0x40)
        mstore(data, 0xff0000000000004946c0e9F43F4Dee607b0eF1fA1c0000000000000000000000)
        mstore(add(data, 53), 0x3c1644c68e5d6cb380c36d1bf847fdbc0c7ac28030025a2fc5e63cce23c16348)
        let ptr := add(data, 21)
        for { } lt(i, end) { i := add(i, 1) } {
            mstore(ptr, i)
            pop(call(gas(), keccak256(data, 85), 0, 0, 0, 0, 0))
        }
    }

为简洁起见, destroyChildren() 字节码的反汇编是由阅读器来完成的,总的流程与 GST2 类似,但是进行了一些修改来降低 CREATE2 目标地址查找的难度 —— 这就是效率提高 10% 的由来。

为什么要关注 GasToken

2020 年之前,几乎没有人公开关注 GasToken 1、2 或 CHI。然而,到了 2020 年,DeFi 热潮引发了 “gas 大战”,gas 费飙升至 500 GWei 以上,并触发了 Geth 的默认设置内存池溢出 —— 导致以太坊交易丢失!

然而,在这个默默无闻的以太坊小工具上出现的讽刺事件是,当网络拥堵最严重时,GasToken 的价格(以美元计价)也在 Uniswap 等去中心化交易所上达到顶峰。因此,卖出 GasToken 来赚取利润的生意,因为 gas 本身价格的高涨,并不令人轻松;而且,小数额的卖出,很容易错过一段时间内的高点。(注:这绝不是投资建议。)

根据定义,GasToken 当然是最具实用性的代币,因为它直接充当网络的交易池。有些人建议使用 GasToken 来实现一种基于合约的公益品融资。或许这比 Near Protocol 强制规定的智能合约开发者收取基础交易 gas 成本总额的 30%(后者也存在自身的问题,例如,鼓励效率低下的智能合约设计)更好。

非正统 GasToken

DefiSaver 旨在为用户提供更加友好的方式,以便其与不同的 DeFi 协议交互。这一工具通过函数修饰符在合约中使用 GasToken。这个修饰符使用正统的 GST2 合约,目前在几乎所有 DefiSaver 包装的协议函数调用中都使用硬编码的值进行调用。一个有趣的分析是,随着时间的推移,这种方法可以节省多少交易费。Tenderly 等新型以太坊工具凭借其优越的 GasProfiler 和仿真模式使之成为可能。

虽然这种硬编码模式肯定有效,但是经过改进的设计需要依赖当前 gas 价格——这时,chainlink 等信息输入机制就派上了用场。设计上必须谨慎,因为这可能会带来很高的成本(lastestAnswer() 的成本约为 15000 gas)。

其它著名用例/设计有 GasToken 工厂和将 CHI GasToken 纳入 MakerDAO 质押品的提案

铸造 GasToken

那么,为什么没有更多合约使用 GasToken?状态膨胀(即,节点的存储量大小)的问题越来越严重,或许这就是 GasToken 被视为有害状态操作的原因。就像一些持纯粹主义的比特币持有者拒绝采用 OP_RETURN 比特币脚本操作码来 存储/销毁 比特币区块链上的任意数据的做法,称这会导致不必要的状态膨胀。

状态租赁这一想法似乎已经被放弃,一方面是因为可能会引入过多的复杂性,另一方面是因为无状态客户端的出现和 ETH 2.0 有望引入另一种状态存储架构。虽然可能性很低,但是 ETH 1.0 的矿工可能会抵制状态膨胀,选择审查类似 GasToken 的机制的交易,因为状态膨胀会直接增加运行全节点的成本,尽管增加的成本很少 —— 256 比特的存储插槽的真正成本几乎可以忽略不计。

另一个更加实际的因素是,GasToken 从中长期来看存在操作码重新定价的风险。

没有风险的修改提议

由于伊斯坦布尔硬分叉引入了 EIP 2200,存储操作码已经过大规模重组,不过这些更改涉及特定情况下的 记账/计量方式;SLOAD 的 gas 价格上涨,SSTORE 则没有。

最近,EIP-2929 提议了一些修改。这些修改源自一篇帝国理工学院(Imperial College London)的论文。此前,这篇论文还被用来详细分析操作码的 gas 定价(过低)问题。这个 EIP 提议增加交易首次使用 SLOAD*CALLBALANCEEXT*SELFDESTRUCT 所需的 gas 成本,因为考虑到这些操作码读取的状态量和访问状态所需的时间,它们都存在定价过低的问题。

特别要指出的是,这个 EIP 流程提议增加交易范围内的 addresses_accessedaccessed_storage_keys 集合,以便区分冷热状态访问,向冷账户/状态访问收取额外的 2600 gas,并将热状态存储访问的 gas 成本减少至 100 gas。

由于 COLD_SLOAD_COST 是基于 SSTORE_RESET_GAS收费的,基于存储的 GasToken1 的经济机制就不那么有吸引力了。GasToken 1 似乎不常用,因为它只能在较小的 GasPrice 率范围内节省成本。所以再见了,GasToken1。

SELFDESTRUCT 的修改提议不会影响 GST2 或 CHI 和 free*() 部分,因为 gas 退款的接收方 parent 合约已经在 addresses_accessed集合内。但是,如果该机制采用不同的设计,如,接收退款的地址不在 addresses_accessed 集合内,那就不同了。但是,所有这些都不是断言 GasToken 的经济模型会改变,或是使之不那么 有效/可行。

Gas 在 2020 年发生了什么 ?

Eth 1.x 社区有一个提议是,增加一个记账单位,以便进行计算。这个单位被命名为 oil(石油),与 gas 并行运作(操作码成本和初始限制相同),但是存在以下几点关键区别:

  • 如果交易在执行过程中将 oil 耗尽,交易可还原。在 gas 机制中,如果 gas 耗尽,交易只能还原当前帧,并让调用者检查结果。相比之下,如果 oil 耗尽,整个交易都会还原(所有帧)。
  • 不同于 gas,调用者合约无法限制被调用者合约可以使用的 oil 数量。
  • 交易所退回的以太币数量将基于剩余的 oil 而非 gas 来计算。

上文所提到的 “帧(frame)” 指的是合约环境,即,执行合约时涉及到的 内存/状态 区域。这当然是一个有趣的提议,也许会增加复杂性,但是从最初的方案来看,似乎不会打破正统的 GasToken 合约(请注意,这是 oil 机制的目标),可以保证大多数合约的向后兼容性。关于 oil 概念/机制的更多内容不在本文的讨论范围内,但是各位读者可以关注一下。

玩梗时间

破产(Broke):在网络发生拥堵时支付 gas 价格

未雨绸缪(Woke):在网络通畅时铸造 GasToken,在网络拥堵时释放 GasToken

定制化(Bespoke):将 GasToken 动态整合到你的合约设计中,使用链上 gas 价格输入机制在有利可图时触发 GasToken 机制

- 要找到一张有趣的燃气表的图片真的太难了 -

关于 GasToken 及其动态还有很多可以说。我支持那些提出这一想法的。GasToken 不仅有趣,还能鼓励人们更好地理解 EVM,推动对状态维护、合约设计和 gas 市场动态的深入思考。

如果你了解更多炫酷的 GasToken 应用/用例,请通过推特联系我,或在评论区写下评论。

(完)


原文链接: https://medium.com/coinmonks/gastoken-or-how-i-learned-to-stop-worrying-and-love-gas-price-surges-6aaee9fb0ba3
作者: Aodhgan Gleeson
翻译&校对: 闵敏 & 阿剑


你可能还会喜欢:

以太坊可扩展性挑战:状态数据

深处的蚁穴:与 Gas 相关的三种安全问题

 
0 人喜欢