Ethereum Tx and Meta Tx

Table of Contents

1. Tx 格式及 Tx 广播过程

用户往 Ethereum 上提交 Tx 的过程如图 1 所示,图示省略了 Mempool (Pending Transaction Pool)。

eth_submit_tx.svg

Figure 1: Submit Tx to Ethereum

从图 1 中可知,我们是通过 RPC 方法 eth_sendRawTransaction 往节点发送交易数据的。 eth_sendRawTransaction 的输入参数由图 2 所示方法构造, 谁对 Tx 进行签名就从谁帐户中转移 value 指定的 ETH 数量到 to 指定的帐户中,同时还从签名帐户中扣除该 Tx 的 gas 消耗。 普通的 ETH 转账交易和合约调用交易的格式是一样的,只是在 ETH 转账交易中 input data 往往为空,而在合约调用交易中 input data 中编码了调用合约需要的相关数据。

eth_sendRawTransaction.svg

Figure 2: Input of eth_sendRawTransaction

在计算签名时,一共包含了 9 个元素:

  • nonce: 帐户 nonce 值
  • gas price: 设置的 gas 价格,如果值太低则打包会很慢(甚至不会被打包)
  • gas limit: 设置的 gas 数量的最高使用限制,这只是一个最高限制,真实扣除的 gas 还是实际消耗的 gas 值。如果设置 gas limit 小于真实消耗的 gas 值则会报错
  • to: 目标地址
  • value: 转移 ETH 数量
  • input data: 对于普通的 ETH 转账往往为空,对于合约交易则是调用合约需要的相关数据
  • chain id: 链 id(如主网为 1,Ropsten 为 3,Rinkeby 为 4,Kovan 为 42),EIP-155 中增加
  • 0: 固定为 0,EIP-155 中增加
  • 0: 固定为 0,EIP-155 中增加

在 Ethereum Classic 分叉以前,计算签名时仅使用了前面 6 个元素,EIP-155 增加了后面 3 个元素(把 chain id 也放到签名数据中了),这主要是为了避免不同链上的重放攻击,比如某人在 Ethereum Classic 提交一个 Tx,如果签名信息中不包含 chain id,则可以重放到 Ethereum 主网上,详情可参考 EIP-155

1.1. EIP-2718

为了使以后对 tx 的扩展更加方法,在 EIP-2718 中,规定 tx 有两种形式:

LegacyTransaction                                    # 形式一,这是 EIP-155 tx
TransactionType || TransactionPayload                # 形式二,这是新 tx,TransactionType 占一个字节,范围为 [0x00 - 0x7f]

其中 || 表示 concatenation 的意思。

怎么区别这两种形式呢? 如果 tx 的首字节是 [0x00 - 0x7f] 则认为是新 tx;否则,则认为是 EIP-155 tx。

TransactionType 到底是多少,这由其它的 EIP 来定义。

1.2. EIP-2930(Txn Type 为 1)

EIP-2930 中定义了 TransactionType 为 0x01 的 tx,它的格式为:

0x01 || rlp([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])

1.3. EIP-1559(Txn Type 为 2)

EIP-1559 中定义了 TransactionType 为 0x02 的 tx,它的格式为:

0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])

在 EIP-1559 中,tx 数据中 gasPrice 字段已经不见了,取而代之的是用户在 tx 中可以设置另外两个字段:maxPriorityFeePerGas 和 maxFeePerGas。

在 EIP-1559 中规定,用户付出的 gas 费有两个去向:一是直接销毁,二是给 miner。 为了理解这点,请看表 1 中的 3 个变量。

Table 1: EIP-1559 中 3 个相关变量
变量 含义
baseFeePerGas 这个值用户无法设置,在每个区块头中设置,协议会动态调节它。用户 tx 的 gas 费中直接销毁的部分为: baseFeePerGas * gasUsed。
maxPriorityFeePerGas 用户在 tx 中设置。用户给 miner 的最大 gas 费为 maxPriorityFeePerGas * gasUsed。
maxFeePerGas 用户在 tx 中设置。 用户使用这个值来设置他愿意为 tx 付出的最大 gas 费,具体这个最大 gas 费就是 maxFeePerGas * gasUsed。 The difference between maxFeePerGas and baseFeePerGas + maxPriorityFeePerGas is “refunded” to the user.

用户最终付出的 gas 费是多少呢?当 baseFeePerGas + maxPriorityFeePerGas 小于 maxFeePerGas 时,用户付出的 gas 费就是 (baseFeePerGas + maxPriorityFeePerGas) * gasUsed ;而当 baseFeePerGas + maxPriorityFeePerGas 大于 maxFeePerGas 时,用户付出的 gas 费就是 maxFeePerGas * gasUsed 。也就是说用户最终付出的 gas 费是 maxFeePerGas * gasUsed(baseFeePerGas + maxPriorityFeePerGas) * gasUsed 两者中的较小值:

min(maxFeePerGas * gasUsed, (baseFeePerGas + maxPriorityFeePerGas) * gasUsed)

当然,需要注意的是 maxFeePerGas 不能比 baseFeePerGas 小,这样的 tx 是非法的。

考虑实例:

baseFeePerGas = 100
maxPriorityFeePerGas = 5
maxFeePerGas = 200

由于 baseFeePerGas + maxPriorityFeePerGas < maxFeePerGas,所以用户最终付出的 gas 费为 105 * gasUsed

再看另外一个实例:

baseFeePerGas = 100
maxPriorityFeePerGas = 5
maxFeePerGas = 102

由于 maxFeePerGas < baseFeePerGas + maxPriorityFeePerGas,所以用户最终付出的 gas 费为 102 * gasUsed

EIP-1559 交易在伦敦硬分叉后被激活,当然传统的 EIP-155 交易也被兼容支持。对于 EIP-155 交易产生的 gas 费用, baseFeePerGas * gasUsed 被直接销毁, (gasPrice - baseFeePerGas) * gasUsed 将会给 miner。

参考:Cheatsheet: 1559 for Wallets & Users

1.4. EIP-4844(Txn Type 为 3)

EIP-4844 中定义了 TransactionType 为 0x03 的 tx,它的格式为:

0x03 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, to, value, data, access_list, max_fee_per_blob_gas, blob_versioned_hashes, y_parity, r, s])

2. Meta Tx (Gasless Tx)

提交 Tx 时,会消耗 Tx 签名者的 ETH(数量为 gasUsed * gasPrice)。这意味着如果你的地址中没有 ETH,则无法调用智能合约的方法(当然那些只读的、不会改变合约状态的方法不用 gas 就可以调用,我们不讨论这些方法)。

Meta Transaction(也称 Gasless Transaction)提供了一种方式,用户可以不需要 gas 就调用智能合约的方法,其消耗的 gas 费用由第 3 方(往往称为 Relayer)来支付,这样就实现了用户可以“免 gas 调用合约”。 Dapp 的开发者可以使用“免 gas”这样的宣传来推广用户,当然 Dapp 开发者需要为 Replayer 付出的 gas 通过某种形式买单,否则没人会充当 Relayer 角色。

要实现“用户免 gas 调用合约”,Relayer 并不能简单地转发用户的签名交易给节点,因为在计算 gas 时,谁对 Tx 进行签名就从谁帐户中扣除 gas 费用,所以 Relayer 需要用自己的私钥对 Tx 重新签名打包。

假设 User 为 Account A;Smart Contract 为 Account B;Relayer 为 Account C。Relayer 可以进行图 3 所示的重新打包操作吗?

eth_relayer_bad.svg

Figure 3: Relayer 仅仅换个签名再打包(这种方法有问题!)

答案是不行的,因为图 3 的做法有两个问题:

  1. Relayer 可以随意修改 User 提交的交易数据。比如修改 input data,本来 User 想调用合约函数时指定的参数为 arg1,现在 Relayer 可以改为 arg2。
  2. 智能合约中的 msg.sender 变为了 Account C,这样合约根本不知道这个交易原本是由 User (Account A) 提交的。也就是说智能合约认为就个交易就是 Relayer (Account C) 发起的,这和 Dapp 开发者通过 Meta Tx 推广用户的初衷是不符的。

2.1. Meta Tx 基本原理

为了解决前面提到的第 1 个问题,我们让 User 对调用合约函数时指定的参数进行签名,并把这个签名也发往合约中,在合约中去校验签名的正确性。由于从签名数据中可以恢复出签名者的帐户地址,所以合约有了签名也就能得到 User 的帐户地址,这样第 2 个问题也解决了。 这个过程需要对智能合约的代码进行修改。

假设 User (Account A) 调用合约的函数 func1(a,b) ,现在需要把这个调用改造为“免 gas”的。设改造前 func1 的伪代码如下:

func1(a, b) {
  userAddr = msg.sender

  // Do something
}

要支持 Meta Tx,则需要把上面函数改为:

func1BySig(a, b, signatureA) {  // signatureA 是 User 对 a,b 进行的签名
  // 1. 验证 signatureA 确实是从参数 a,b 进行的签名而来,这样 a,b 就不能被 Relayer 修改了。
  // 2. 从签名中恢复出用户地址,这样合约就知道了这是由用户 Account A 发来的请求。
  // 注:1 和 2 可以通过 solidity 的函数 ecrecover 来实现。

  // 用户地址不再是 msg.sender(这是 Relayer 地址),从签名中“恢复出来的地址”才是用户地址。
  userAddr = addr recovered from signatureA

  // Do something
}

对合约函数的修改可以总结为:

  1. 合约函数需要增加参数(如前面例子中的 signatureA,也有增加 3 个参数 V/R/S 的,它们表示的也是签名数据)接收 User 的签名,并在合约中校验签名;
  2. 合约函数内部不能使用 msg.sender 作为用户地址,从签名中恢复的地址才是用户地址。

关于在合约中处理签名数据的规范,可以参考 EIP-191: Signed Data StandardEIP-712: Ethereum typed structured data hashing and signing
关于把合约函数改造为支持 Meta Tx 的具体例子,可以参考 Comp 合约的 delegateBySig 函数 ,它实现了同合约中 delegate 函数的相同功能,只不过 delegateBySig 支持 Meta Tx。

2.2. 中心化的 Relayer

假设 User (Account A) 想不消耗 gas 调用 Contract (Account B) 的函数 func1BySig (这是为了支持 Meta Tx 从 func1 改造而来的函数)。利用前面介绍的知识我们可以设计出图 4 所示的中心化的 Relayer。

eth_relayer_centralized.svg

Figure 4: 中心化的 Relayer

2.3. 去中心化的 Relayer

Gas Station Network (GSN) 是一种去中化的 Relayer 方案,这里不介绍。

2.4. 安全性

使用 Relayer 时,为了安全起见我们需要防止 User 生成的 txdata 被 Relayer 重复提交。对此,Comp 合约的 delegateBySig 函数 采用了下面措施:

  1. 为每个用户记了一个 nonce 值,每当合约函数被调用一次,nonce 就增加 1。由于 nonce 包含在签名中,每次用户在生成签名前,需要去合约中查询目前的 nonce 值是多少,就算函数其它参数都一样,每次生成的签名也会不一样。这样一个 txdata 不可能被成功执行两次。
  2. 引入 expiry,这是一个额外的安全措施,如果生成的 txdata 超过一定时间没有被提交,那么会过期。

上面是从合约层面做的一些安全措施。不过,执行失败的 Tx 也会消耗 gas,所以 Relayer 本身也需要一些安全措施(如对 Dapp 每个用户每天提交的 Meta Tx 数量进行限制等)来避免恶意消耗 gas。

Author: cig01

Created: <2020-06-06 Sat>

Last updated: <2024-02-26 Mon>

Creator: Emacs 27.1 (Org mode 9.4)