以太坊 GAS 燃料和交易手续费

# 概述

在以太坊 London 升级后,以太坊启用了 EIP1559 进行 gas 计算。由于 EIP1559 引入的新的 gas 机制较为复杂,所以我写了此文介绍了以太坊的 gas 机制。

# 本文主要涉及以下内容:

  • EIP1559 引入的新的 gas price 设置方式
  • 交易花费的具体计算方式

# 概念辨析

首先在此处介绍 gasgas price 的区别。

前者是以太坊转账或者合约操作的基准价值。你可以在此网站查询到每一个操作码的最小 GAS 消费。如下图:

理论上,我们可以通过合约字节码判断出合约操作所需要的 gas 值。当然,如果读者使用了 Foundry 作为智能合约开发工具链,可以在合约代码根目录运行 forge test --gas-report 获得 gas 报告,如下图:

上述表格也显示了合约部署消耗的 gas 值。当然,以太坊中也有一种不需要与智能合约交互的但非常重要的操作就是 ETH 转账,此操作被规定为 21,000 。可以参考此交易,如下图:

如果你自定义交易的 gas 最大限额,但设置的数量小于合约操作所需要的 gas ,就会出现错误。比如这个交易,如下图:

上图由红框框出的部分就是此交易的 gas 限制和 gas 实际用量。此操作实际的 gas 用量为 160,596,此处的最大限额小于合约操作的用量,所以出现了错误。正常的合约操作可以参考此交易。当然此交易虽然失败了,但仍打包到区块内并收取交易手续费并奖励矿工。因为矿工在接受交易时并不清楚交易的 gas 用量,矿工会运行交易直至 gas 耗尽,此部分需要补偿矿工。

当 Gas 的实际用量小于 Gas Limit 时,剩余部分会退还给用户。

gas 并不代表着进行这一操作所消耗的 ETH 数量。以太坊中存在大量的交易,我们需要根据网络情况调整手续费,为了有效调整手续费,以太坊引入了 gas price 价值作为计算手续费的单位,具体计算公式为 Transaction Fee = Gas * Gas Price ,其中 Transaction Fee 就是交易手续费的意思。在后文中,我们会详细分析 gas price 的计算方法。

# Gas Limit 的获取

对于 Gas Limit 的获取,以太坊客户端给出了一个专用的 RPC API,被称为 eth_estimateGas

此 API 调用所需要的参数其实就是交易所需要的参数,我们在此处直接给出两个示例帮助大家使用。

在后文中,我们主要使用 Cloudflare 提供的公用以太坊网关作为 RPC API 服务商,其地址为 https://cloudflare-eth.com/v1/mainnet

为了方便学习,此处我们使用以太坊官方文档提供的线上测试功能。读者可以通过以下方法打开测试功能:

首先,我们尝试获取转账交易的 Gas 消耗,在上图给出的测试栏的的左侧输入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
{
"jsonrpc": "2.0",
"method": "eth_estimateGas",
"params": [
{
"from": "0x8D97689C9818892B700e27F316cc3E41e17fBeb9",
"to": "0xd3CdA913deB6f67967B99D67aCDFa1712C293601",
"value": "0x186a0"
}
],
"id": 0
}

输入完成后点击运行按钮,我们可以在右侧获得以下返回:

1
2
3
4
5
{
"jsonrpc": "2.0",
"result": "0x5208",
"id": 0
}

其中, result 就是此交易的 gas ,将其转为十进制,结果恰好为 21000 ,与上文给出的结果相符。

当然,更常见的 Gas 估计是估计合约操作所消耗的 Gas 值,我们在此处以 WETH 合约 (0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2) 为例获取存储 deposit() 操作的 Gas 消耗。

使用此 API 的具体参数可以参考以下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"jsonrpc": "2.0",
"method": "eth_estimateGas",
"params": [
{
"type": "2",
"from": "0x8D97689C9818892B700e27F316cc3E41e17fBeb9",
"to": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"value": "0x186a0",
"input": "0xd0e30db0"
}
],
"id": 0
}

其中各个参数意义如下:

  • from 调用合约的用户地址
  • to 目标合约地址
  • value 在调用合约时发送的 ETH
  • input 调用合约时发送的 Calldata

input 可以在此网站获得。获得 deposit() 函数调用 Calldata 的形式如下图:

由于此处 deposit() 没有参数,所以我们没有在此处使用 Add argument 增加参数。
发送上述请求,我们可以获得以下返回值:

1
2
3
4
5
{
"jsonrpc": "2.0",
"result": "0xafee",
"id": 0
}

result 转换为十进制得到 45038 ,这与我们在此页面查询得到的结果一致。

对于获取 gas 的估计值,我们也可以使用 cast 获得,在此处,我们仍使用 WETH 合约。

在终端内输入

1
2
3
cast estimate 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 \
--value 1.1ether "deposit()" \
--rpc-url https://cloudflare-eth.com/v1/mainnet

我们可以获得返回值为 27938 。读者可以发现此交易的 gas 正是 27938

上述两者的不同原因是在 EIP2929 。简单来说, SSTORE 操作符的 gas 决定方式较为特殊。此操作符用于向合约特定的存储槽内写入数据。其 gas 决定方法如下:

  • 当写入存储槽本来无数据时,使用 SSTORE 写入数据消耗 22100 。如果读者的地址未持有 WETH 时,我们需要消耗此数值的 gas
  • 当写入存储槽内存在非零数据时,使用 SSTORE 写入数据消耗 5000

当我们使用 cast estimate 评估 gas 时,默认使用的地址内存在 WETH,而在我们上文使用的 RPC API 时,使用的地址内不持有 WETH。更加详细的 Gas 分析我们会在后面几篇内给出。

# Gas Price 计算

我们主要考虑在 London 升级后的符合 EIP1559 标准的交易,这些交易均被标记为 type 2

# 名词解释

在此处,我们给出一个交易的实例:

我们主要考察 Gas Price 这一栏。内部由以下构成:

  • Gas Limit & Usage by Txn 我们在上文进行了解释,前者表示合约操作的 Gas 限额,后者表示本次交易的 Gas 用量
  • Gas Fees 给出 Gas Price 的各个计算参数
    • Base 基础 Gas Price
    • Max 最大 Gas Price
    • Max Priority 支付给以太坊节点矿工的 Gas Price
  • Burnt & Txn Savings Fees 燃烧掉的手续费和给予矿工的手续费
    • Burnt 燃烧的手续费。EIP1559 规定了每次交易的手续费部分进行燃烧,这一行为有效避免了 ETH 通货膨胀
    • Txn Savings 给予矿工的手续费

我们会在下文给出每个参数的计算方法。

# Base Fee

此参数由以太坊网络计算得到,在同一区块内是固定的。如果你设置 的Base Fee 的小于当前网络的 Gas Fee ,则交易永远不会被打包。

我们在此处给出 go-ethereum源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// CalcBaseFee calculates the basefee of the header.
func CalcBaseFee(config *params.ChainConfig, parent *types.Header) *big.Int {
// If the current block is the first EIP-1559 block, return the InitialBaseFee.
if !config.IsLondon(parent.Number) {
return new(big.Int).SetUint64(params.InitialBaseFee)
}

parentGasTarget := parent.GasLimit / params.ElasticityMultiplier
// If the parent gasUsed is the same as the target, the baseFee remains unchanged.
if parent.GasUsed == parentGasTarget {
return new(big.Int).Set(parent.BaseFee)
}

var (
num = new(big.Int)
denom = new(big.Int)
)

if parent.GasUsed > parentGasTarget {
// If the parent block used more gas than its target, the baseFee should increase.
// max(1, parentBaseFee * gasUsedDelta / parentGasTarget / baseFeeChangeDenominator)
num.SetUint64(parent.GasUsed - parentGasTarget)
num.Mul(num, parent.BaseFee)
num.Div(num, denom.SetUint64(parentGasTarget))
num.Div(num, denom.SetUint64(params.BaseFeeChangeDenominator))
baseFeeDelta := math.BigMax(num, common.Big1)

return num.Add(parent.BaseFee, baseFeeDelta)
} else {
// Otherwise if the parent block used less gas than its target, the baseFee should decrease.
// max(0, parentBaseFee * gasUsedDelta / parentGasTarget / baseFeeChangeDenominator)
num.SetUint64(parentGasTarget - parent.GasUsed)
num.Mul(num, parent.BaseFee)
num.Div(num, denom.SetUint64(parentGasTarget))
num.Div(num, denom.SetUint64(params.BaseFeeChangeDenominator))
baseFee := num.Sub(parent.BaseFee, num)

return math.BigMax(baseFee, common.Big0)
}
}

其中 parent 为上一区块的区块头。我们在此处不再详细解释此结构体内的变量,读者可自行查找对应源代码。此处用到的一个重要参数为 parent.GasLimit ,含义为区块内各个交易的 Gas 累加最大值,读者可以通过此网站查看历史上的 GasLimit 变化。目前 (2022 年 8 月),此值大概为 3 千万。

1
2
3
4
5
Miner: miner.Config{
GasCeil: 30000000,
GasPrice: big.NewInt(params.GWei),
Recommit: 3 * time.Second,
}

当然,区块的 GasLimit 并不是固定不变的,会在小范围内波动,具体的计算逻辑位于 go-ethereum 内的 CalcGasLimit (parentGasLimit, desiredLimit uint64) 函数,此函数使用的参数 desiredLimit 即为 3 千万 。限于篇幅且此计算函数较为简单,我们不对计算函数进行详细解释,读者有兴趣可以自行研究此函数。

params.ElasticityMultiplier 值已经在源代码进行了硬编码为 2 。通过 parentGasTarget := parent.GasLimit / params.ElasticityMultiplier 代码,我们可以计算出目前目标区块容量为 1.5 千万。

params.InitialBaseFee 此值为 EIP1559 启动时区块的 baseFee ,从后文我们可以看到计算 baseFee 依赖于上一区块的 baseFee ,而初始区块的上一区块没有通过此属性,所以我们需要进行初始化。此变量被初始化为 const InitialBaseFee untyped int = 10000000001000000000 的单位为 wei ,即 1 gwei

1
2
3
if parent.GasUsed == parentGasTarget {
return new(big.Int).Set(parent.BaseFee)
}

此代码说明,当目前区块交易 Gas 累加值为 1.5 千万时,区块与上一区块的 Base Fee 相同。这也意味着当前 Gas Price 很好平衡了交易数量与交易费用,不需要进行调整。

除了这种相同的情况,还有大于和小于的情况,下面先展示上一区块没有大于目标 Gas 总量的情况。

1
2
3
4
5
6
7
8
9
// If the parent block used more gas than its target, the baseFee should increase.
// max(1, parentBaseFee * gasUsedDelta / parentGasTarget / baseFeeChangeDenominator)
num.SetUint64(parent.GasUsed - parentGasTarget)
num.Mul(num, parent.BaseFee)
num.Div(num, denom.SetUint64(parentGasTarget))
num.Div(num, denom.SetUint64(params.BaseFeeChangeDenominator))
baseFeeDelta := math.BigMax(num, common.Big1)

return num.Add(parent.BaseFee, baseFeeDelta)

在注释中,我们可以看到当前区块的 baseFee 的计算公式为

1
2
parent.BaseFee + 
max(1, parentBaseFee * gasUsedDelta / parentGasTarget / baseFeeChangeDenominator)

其中各个参数意义如下:

  • parentBaseFeeparent.BaseFee ,即上一区块的 baseFee
  • gasUsedDeltaparent.GasUsed - parentGasTarget ,即上一区块的 Gas 总量与目标总量之间的差额
  • parentGasTarget 为上一区块的目标值,在一定时期内可以认为是常量,目前为 1.5 千万 Gas
  • BaseFeeChangeDenominator ,定义为 const BaseFeeChangeDenominator untyped int = 8

我们计算极限情况,即当前区块的上一区块的 Gas 总量到达限额 3 千万,此时 gasUsedDelta1.5parentGasTarget1.5 ,简单计算可以得出当前区块的 BaseFee 应为上一区块的 112.5 %

接下来我们使用 Etherscan Blocks 提供的真实数据进行计算。

我们计算 15406316 区块的 BaseFee ,我们需要参照该区块的上一区块 15406315 的参数进行计算,我们可以看到上一区块的 gasUsedDelta/parentGasTarget为+ 11% ,计算得到此时 15406316BaseFee 的值应为 6.38 Gwei * 0.11 / 8 ,计算得到 0.885225 gwei ,即 15406316baseFee 应为 6.38 * 0.11 / 8 + 6.38 ,计算得到结果为 6.467725 ,与 etherscan 给出的相同。

以下给出上一区块 Gas 总量小于目标总量的代码:

1
2
3
4
5
6
7
8
9
// Otherwise if the parent block used less gas than its target, the baseFee should decrease.
// max(0, parentBaseFee * gasUsedDelta / parentGasTarget / baseFeeChangeDenominator)
num.SetUint64(parentGasTarget - parent.GasUsed)
num.Mul(num, parent.BaseFee)
num.Div(num, denom.SetUint64(parentGasTarget))
num.Div(num, denom.SetUint64(params.BaseFeeChangeDenominator))
baseFee := num.Sub(parent.BaseFee, num)

return math.BigMax(baseFee, common.Big0)

根据代码,我们可以得出计算公式如下:

1
2
parent.BaseFee - 
max(1, parentBaseFee * gasUsedDelta / parentGasTarget / baseFeeChangeDenominator)

这意味着如果上一区块的 Gas 总量为 0 ,则当前区块的 baseFee 为上一区块 baseFee 的 87.5 % 。我们不再给出具体的计算过程,可自行使用 Etherscan Blocks 提供的数据进行验算。

BaseFee 的动态调整可以很好平衡以太坊网络流量,一旦单一区块的交易 Gas 到达 1.5 千万,那么根据上述机制,下一区块就会提高 BaseFee 以增加用户的交易手续费,抑制用户交易。反之,当交易需求不足时,以太坊网络则会降低交易手续费以提高用户的交易欲望。

在上图中,我们可以明显考到这一趋势。在 15406535 区块出现了交易 Gas 为 0 的情况,导致 BaseFee 下降,在下一区块 15406536 则出现了大量交易。

我使用了部分区块的数据绘制了以下图像:

在此图像中,条形图展示了区块的大小,而折线图展示了 Base Fee 的变化,我们可以很明显的看出 Base Fee 对区块大小的调整作用。

此图主要使用了 eth_getBlockByNumber 方法获得区块数据。

根据 EIP1559 规定, baseFee 不归属于矿工而会被直接燃烧。这种燃烧行为有效避免 ETH 通货膨胀。通过 Etherscan EIP1559 Dashboard 可以获得对应的数据,如下图:

ETHW 项目作为以太坊合并后的 POS 分支废除了 EIP1559,很明显,EIP1559 没有将所以的手续费分配给矿工的行为不被部分以太坊矿工认可。

# Max Priority Fee

在此交易的实例中,我们可以看到 Max Priority1 Gwei 。相比于上文给出的 BaseFee 而言,此变量完全由交易者自己规定,而不涉及计算问题。 Max Priority FeeBase Fee 不同,此手续费完全交给矿工。所以此值越高则意味着被提前打包的概率越大。

此数值可以通过交易内存池 ( mempool ) 中的交易数据进行推测,目前市面由很多网站提供 Max Priority Fee 的参考数值,比如:

我们在此处以 BlockNative 提供的数据为例,如下图:

BlockNative 显示了在当前区块确认交易所需要的 Priority FeeMax Fee 以及当前区块的 Base Fee 。关于 Max Fee 的设置,我们会在下文进行介绍。

此处我们以 MetaMask 为例 (版本为 10.18.3 ),给出 EIP1559 的设置方法。在进行转账或其他操作时,我们可以点击 编辑 ,如下图:

在弹出页面内选择 高级选项 ,我们就可以手动调整各个参数,如下图:

由于此处为转账操作,所以 燃料限制 ,即 Gas Limit21000 。其他数值我们可以自行调整。一般来说, MetaMask 填入的默认数值是可以直接使用的,但当遇到铸造 NFT 等场景时,我们可以手动调高 Max Priority Fee 以提高铸造成功率。

有了以上参数,我们可以计算具体的交易手续费。我们仍是使用示例交易为大家介绍。

我们可以看到此交易的 Base7.326319867 Gwei ,而 Max Priority1 Gwei 。将上述两个数累加即 gas price ,此处计算得到 8.326319867 Gwei 。然后我们将 gas price * gas ,即 8.326319867 * 45038 ,得到此交易的手续费为 375000.79416994605 Gwei ,基本与 Transaction Fee 的值一致。

# Max Fee

我们最后介绍 Max Fee 。此数值规定交易的最大 gas price 。可能有读者会疑惑,我们已经设置了 Base FeeMax Priority Fee ,为什么还需要 Max Fee

原因在于用户提交给以太坊节点的交易不一定在下一个区块内完成。如果读者还记得上文给出的 BaseFee 就知道此数值是随着区块 Gas 总量不断变化的。假如我们根据区块 0 计算出下一区块 1 的 BaseFee7 Gwei ,同时手动设置了 Max Priority Fee1 Gwei ,由于我们给出的矿工小费太少,我们的交易会进入打包序列但可能无法在区块 1 内打包。只能等待区块 2 进行打包,但极有可能出现区块 2 的 BaseFee7.875 Gwei 高于区块 1,我们给出的 BaseFee 小于区块 2 的 BaseFee ,此时交易会被直接抛弃,造成交易失败。

如果我们给出 Max Fee 参数为 9 Gwei ,当交易进入区块 2 时,区块 2 会根据 Max Fee 计算出我们可以承受的 Base FeeMax Fee - Max Priority Fee8 Gwei ,此数值大于区块 2 的 Base Fee ,交易仍会保存在序列中等待打包。

简单来说, Max Fee 的设置可以保证交易不会在未来几个区块内因为 Base Fee 设置过低问题而被抛出打包序列。此数值设置越高,你的交易会在打包序列中保存的时间越长,避免因手续费问题而交易失败。

比如这个 Binance 的交易给出了超高的 Max Fee ,彻底避免在因 Base Fee 而出现交易失败的问题。

读者可以估计以下自己目标交易在几个区块内完成,然后设置 Max Fee 。当然, BlockNative 提供了一种简单的计算方法,公式如下:

1
Max Fee = (2 * Base Fee) + Max Priority Fee

这种计算方法可以保证即使用户遇到连续 6 个满区块 (即区块 Gas 总额均达到 3 千万) 仍可以保证交易不会被提出打包序列。

连续 6 个满区块会导致相对于当前的 BaseFee(1.125)^6 ,计算可知此倍数为 2.027

读者可以根据自己的情况设置 Max Fee 。但不建议 Max FeeBase Fee 的值差距较小,这可能会导致交易无法完成。

# 总结

本篇主要介绍了以下内容:

  • 以太坊中的 GasGas PriceTransaction Fee 之间的区别
  • EIP1559 中各个参数的计算方法和功能

我们可以通过下图简单总结本文: