理解以太坊交易和消息:从状态变更到链下消息——第一部分

本文详细介绍了以太坊中的交易类型和消息,包括交易(Legacy Transaction、EIP-2930 Access List Transaction和EIP-1559 Dynamic Fee Transaction)和消息(EIP-191 Signed Data)。

理解以太坊交易和消息:从状态变更到链下消息 — 第一部分

在之前的文章中,我们深入研究了 EVM 在最低层面的工作原理:内存布局、calldata 编码、gas 机制和存储内部结构。但现在是更上一层楼的时候了。

这篇文章开启了本系列的新阶段。重点不再是 EVM 如何执行,而是开发者和用户如何在现实世界中与链交互。我们现在处理的是 transactions、messages、RPC 行为和实际的工具难题

在这篇文章中,我们将从高层次了解主要的以太坊 transaction 类型,它们是如何演变的,以及它们如何在链上出现。我们正在奠定基础:这些类型是什么,以及如何识别它们。在未来的文章中,我们将更深入地研究每种 transaction 类型的内部结构,探讨它们被引入的原因,并涵盖这里没有完全涵盖的类型。

我们将探讨:

1. Transaction 类型和消息。它们是什么?

2. 什么是递归长度前缀(RLP)序列化

3. 传统 Transaction

4. EIP-2930 Transaction:访问列表 Transaction

5. EIP-1559 Transaction:动态费用 Transaction

6. EIP-191:个人签名消息

Transaction 类型和消息。它们是什么?

重要的是要认识到,从用户的角度来看,EVM 兼容链上有两种主要的签名数据类型

Transactions

签名后的 payloads,用于更改链上的状态

  • 提交到 mempool 或私有 tx pools。
  • 最终被挖掘到区块中(或被拒绝)。

transaction 的目的是指示 EVM 执行某些修改区块链状态的操作:发送 ETH、调用合约、部署新合约或与 DeFi 协议交互。EVM 上的每个状态转换都是 transaction 的结果。

目前现有的 transaction 类型有:

Messages

由用户私钥签名的链下数据。

用于身份验证、链下操作、gasless txs、许可流程等。

了解这两者之间的区别至关重要,尤其因为 MetaMask、WalletConnect 甚至智能合约前端等工具通常都依赖于两者。

什么是递归长度前缀(RLP)序列化

递归长度前缀(RLP)是整个以太坊执行层中使用的序列化格式。它旨在以紧凑、标准化的方式对数据结构进行编码,从而使执行客户端可以高效地通过网络共享数据。它不关心值是字符串、浮点数还是地址,它只看到原始二进制数据。如何解释该二进制数据(作为字符串、数字等)留给使用 RLP 的协议。唯一的例外是正整数,必须将其编码为大端二进制,且没有任何前导零。这意味着数字 0 被视为空字节数组,如果反序列化数字时带有额外的前导零,则认为它是无效的。 ](https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/#definition)

golang 中 RLP 编码的示例:

注意完整的 GitHub 代码 + 测试参考在文章底部。

func RlpEncode(input any) []byte {
 switch v := input.(type) {
 case string:
  data := []byte(v)
  if len(data) == 1 && data[0] < 0x80 {
   return data
  }
  return append(encodeLength(len(data), 0x80), data...)

 case []byte:
  if len(v) == 1 && v[0] < 0x80 {
   return v
  }
  return append(encodeLength(len(v), 0x80), v...)

 default:
  // 处理任何类型的切片(例如,[]string、[]int、[]any)
  reflectedValue := reflect.ValueOf(input)
  kind := reflectedValue.Kind()

  if reflectedValue.Kind() == reflect.Slice {
   var output []byte
   for i := 0; i < reflectedValue.Len(); i++ {
    item := reflectedValue.Index(i).Interface()
    output = append(output, RlpEncode(item)...)
   }
   return append(encodeLength(len(output), 0xc0), output...)
  }

  // 处理所有整数类型(有符号和无符号)
  if isIntegerKind(kind) {
   n := toInt(reflectedValue)
   if n == 0 {
    return []byte{0x80}
   }
   return encodeInteger(n)
  }

  panic(fmt.Sprintf("unsupported type: %T", input))
 }
}

func encodeLength(length int, offset int) []byte {
 if length < 56 {
  return []byte{byte(length + offset)}
 }

 l := big.NewInt(int64(length))
 limit := new(big.Int).Lsh(big.NewInt(1), 64) // 2^64
 // 不允许长度超过 2^64
 if l.Cmp(limit) >= 0 {
  panic("input too long")
 }

 bl := toBinary(length)
 return append([]byte{byte(len(bl) + offset + 55)}, bl...)
}

func toBinary(x int) []byte {
 if x == 0 {
  return []byte{}
 }
 var buf bytes.Buffer
 for x > 0 {
  buf.WriteByte(byte(x & 0xff))
  x >>= 8
 }
 // 反转以实现大端
 b := buf.Bytes()
 for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
  b[i], b[j] = b[j], b[i]
 }
 return b
}

func encodeInteger(n int) []byte {
 if n < 0 {
  panic("RLP only supports unsigned integers")
 }
 buf := toBinary(n)
 if len(buf) == 1 && buf[0] < 0x80 {
  return buf
 }
 return append(encodeLength(len(buf), 0x80), buf...)
}

func isIntegerKind(kind reflect.Kind) bool {
 switch kind {
 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
  reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
  return true
 default:
  return false
 }
}

func toInt(v reflect.Value) int {
 // 转换为 int(如果需要更大的范围,可以使用 int64)
 switch v.Kind() {
 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
  return int(v.Int())
 case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
  return int(v.Uint())
 default:
  panic("not an integer kind")
 }
}

注意:RLP 旨在编码结构,而不是数据类型,因此它不对“浮点数”、“布尔值”或“有符号整数”等内容做任何假设。

Decoding(解码)

你可以在此处找到有关 RLP 解码规则的信息。

golang 中 RLP 解码的示例:

// RlpDecode 将 RLP 编码的字节切片解码为 Go 值。
// 它返回 []byte 或 []any,表示列表。
func RlpDecode(input []byte) (interface{}, error) {
 val, _, err := decodeItem(input)
 return val, err
}

// decodeItem 处理单个 RLP 值,该值可以是:
// - 单个字节
// - 字符串(短或长)
// - 列表(短或长)
func decodeItem(data []byte) (any, int, error) {
 if len(data) == 0 {
  return nil, 0, errors.New("empty input")
 }

 prefix := data[0]

 switch {
 // 情况 1:单个字节(0x00 到 0x7f) — 值是字节本身
 case prefix <= 0x7f:
  return data[:1], 1, nil

 // 情况 2:短字符串(0x80 到 0xb7)
 // 第一个字节 = 0x80 + 字符串长度
 case prefix <= 0xb7:
  strLen := int(prefix - 0x80)
  if len(data) < 1+strLen {
   return nil, 0, errors.New("short string too short")
  }
  return data[1 : 1+strLen], 1 + strLen, nil

 // 情况 3:长字符串(0xb8 到 0xbf)
 // 第一个字节 = 0xb7 + 长度的长度 (lenOfLen)
 // 下一个 lenOfLen 字节 = 字符串的实际长度
 case prefix <= 0xbf:
  lenOfLen := int(prefix - 0xb7)
  if len(data) < 1+lenOfLen {
   return nil, 0, errors.New("long string length prefix too short")
  }
  strLen := decodeLength(data[1 : 1+lenOfLen])
  if len(data) < 1+lenOfLen+strLen {
   return nil, 0, errors.New("long string too short")
  }
  return data[1+lenOfLen : 1+lenOfLen+strLen], 1 + lenOfLen + strLen, nil

 // 情况 4:短列表(0xc0 到 0xf7)
 // 第一个字节 = 0xc0 + 编码项的总 payload 长度
 case prefix <= 0xf7:
  listLen := int(prefix - 0xc0)
  if len(data) < 1+listLen {
   return nil, 0, errors.New("short list too short")
  }
  items, err := decodeList(data[1 : 1+listLen])
  return items, 1 + listLen, err

 // 情况 5:长列表(0xf8 到 0xff)
 // 第一个字节 = 0xf7 + 长度的长度 (lenOfLen)
 // 下一个 lenOfLen 字节 = 列表 payload 的实际长度
 default:
  lenOfLen := int(prefix - 0xf7)
  if len(data) < 1+lenOfLen {
   return nil, 0, errors.New("long list length prefix too short")
  }
  listLen := decodeLength(data[1 : 1+lenOfLen])
  if len(data) < 1+lenOfLen+listLen {
   return nil, 0, errors.New("long list too short")
  }
  items, err := decodeList(data[1+lenOfLen : 1+lenOfLen+listLen])
  return items, 1 + lenOfLen + listLen, err
 }
}

// decodeList 遍历表示列表 payload 的字节切片,
// 递归地解码列表中的每个 RLP 项。
func decodeList(data []byte) ([]any, error) {
 // 应返回空切片而不是 nil
 if len(data) == 0 {
  return []any{}, nil // 返回空切片而不是 nil
 }

 var result []any
 for len(data) > 0 {
  val, consumed, err := decodeItem(data)
  if err != nil {
   return nil, err
  }
  result = append(result, val)
  data = data[consumed:]
 }
 return result, nil
}

// decodeLength 将大端字节切片解释为整数长度。
// 这用于长度本身被编码的长字符串/列表。
func decodeLength(b []byte) int {
 n := 0
 for _, by := range b {
  // 左移并添加下一个字节(大端)
  n = (n << 8) + int(by)
 }
 return n
}

大多数情况下,你不必自己执行此操作,它将由某些外部库处理。在真实的以太坊开发中,你几乎永远不会手动编写 RLP 编码器。大多数客户端库(如 go-ethereumethers.jspyrlp)会在你签名 transactions、构建区块或与链交互时为你处理它。

在我们深入了解 transaction 类型和示例之前:

对于所有区块链交互,我们将使用 Polygon Amoy testnet

它具有较低的费用并且区块挖掘速度快。确保你的 Metamask 已在 Polygon Amoy 区块链上设置,并有一些资金,你可以从这个 faucet 获取。

让我们编写一些代码来设置我们的钱包以用于 POC 目的。(不要将此用于真实的钱包生成,这仅用于示例)

注意:我将省略 imports 以节省空间。

完整的 GitHub 代码参考在文章底部。

package account

const (
 projectMarker = "transaction-types"
 key1FilePath  = "account1.key"
 key2FilePath  = "account2.key"
)

// getPath 返回目录的绝对路径。
func getPath() string {
 // 获取当前文件的路径
 _, filename, _, _ := runtime.Caller(0)

 // 查找你的项目根文件夹名称的索引
 idx := strings.Index(strings.ToLower(filename), strings.ToLower(projectMarker))
 if idx == -1 {
  panic("project root folder not found in path")
 }

 // 剪切到项目根目录的路径
 rootPath := filename[:idx+len(projectMarker)]

 // 将相对路径附加到根目录
 return filepath.Join(rootPath, "account")
}

// 如果不存在则生成新帐户,否则加载保存的帐户
func GetAccount(accNum int) (*common.Address, *ecdsa.PrivateKey) {
 var priv *ecdsa.PrivateKey

 path := getPath()
 var keyFilePath string
 switch accNum {
 case 1:
  keyFilePath = filepath.Join(path, key1FilePath)
 case 2:
  keyFilePath = filepath.Join(path, key2FilePath)
 default:
  log.Fatal("Invalid account number. Use 1 or 2.")
 }

 if _, err := os.Stat(keyFilePath); os.IsNotExist(err) {
  fmt.Println("Key file not found. Generating new Ethereum key...")
  priv, err = crypto.GenerateKey()
  if err != nil {
   log.Fatal("Failed to generate key:", err)
  }

  privBytes := crypto.FromECDSA(priv)
  err = os.WriteFile(keyFilePath, []byte(hex.EncodeToString(privBytes)), 0600)
  if err != nil {
   log.Fatal("Failed to write key file:", err)
  }

  fmt.Println("New key saved to", keyFilePath)
 } else {
  // 加载现有密钥
  fmt.Println("Loading existing key from", keyFilePath)
  keyHex, err := os.ReadFile(keyFilePath)
  if err != nil {
   log.Fatal("Failed to read key file:", err)
  }

  privBytes, err := hex.DecodeString(string(keyHex))
  if err != nil {
   log.Fatal("Invalid hex in key file:", err)
  }

  priv, err := crypto.ToECDSA(privBytes)
  if err != nil {
   log.Fatal("Invalid private key:", err)
  }
 }

 // 打印地址和密钥
 address := crypto.PubkeyToAddress(priv.PublicKey)
 pubBytes := crypto.FromECDSAPub(&priv.PublicKey)

 fmt.Println("Address:    ", address.Hex())
 fmt.Println("Public Key: ", hex.EncodeToString(pubBytes))
 fmt.Println("Private Key:", hex.EncodeToString(crypto.FromECDSA(priv)))

 return &address, priv
}

在 Metamask 钱包设置好并用 POL 本币充值后,让我们通过 Metamask 为其中一个生成的地址充值。(只需复制打印的地址并将其粘贴到 Metamask 发送收件人中)

传统 Transaction

传统 transactions 是在 EIP-2718 引入类型化 transactions 之前使用的原始以太坊 transaction 格式。它们没有类型前缀,但按照惯例被视为类型 0x0

这些 transactions 包括基本参数:

  • noncegasPricegasLimittovaluedata
  • vrs(签名)

传统 transaction 的 RLP 编码如下所示:

rlp([nonce, gasPrice, gasLimit, to, value, data, v, r, s])
{
  nonce:    "0x0",            // 发送者在此之前进行的 transaction 数量。
  gasPrice: "0x01284a32b000", // 发送者提供的 gas 价格,以 wei 为单位。
  gasLimit: "0x21000",         // 发送者提供的最大 gas 量。
  to:       "0x...",          // 接收者的地址。在合约创建 transactions 中未使用。
  value:    "0x0",            // 传输的值,以 wei 为单位。
  data:     "0x...",          // 用于定义合约创建和交互。
  v:        "0x1",            // ECDSA 恢复 ID。
  r:        "0x...",          // ECDSA 签名 r。
  s:        "0x...",          // ECDSA 签名 s。
}

示例:

package main

import (
 "context"
 "fmt"
 "log"
 "math/big"
 "time"
 "transactiontypes/account"

 "github.com/ethereum/go-ethereum/core/types"
 "github.com/ethereum/go-ethereum/ethclient"
 "github.com/samber/lo"
)

const (
 // Polygon Amoy Testnet 的公共 RPC URL
 NodeRPCURL  = "https://polygon-amoy.drpc.org"
 GasLimit    = 21000   // 简单 ETH 传输的标准 gas 限制
 AmoyChainID = 80002   // Polygon Amoy Testnet Chain ID
 ValueToSend = 0.01e18 // 0.01 ETH
)

func main() {
 acc1Addr, acc1Priv := account.GetAccount(1)
 acc2Addr, _ := account.GetAccount(2)

 ctx := context.Background()
 client, err := ethclient.Dial(NodeRPCURL)
 if err != nil {
  log.Fatal("Failed to connect to Ethereum node:", err)
 }

 nonce, err := client.PendingNonceAt(ctx, lo.FromPtr(acc1Addr))
 if err != nil {
  log.Fatal("Failed to fetch nonce:", err)
 }

 // 获取建议的 gas 价格
 gasPrice, err := client.SuggestGasPrice(ctx)
 if err != nil {
  log.Fatal("Failed to fetch gas price:", err)
 }

 // 创建一个 types.LegacyTx
 tx := types.LegacyTx{
  Nonce:    nonce,
  GasPrice: gasPrice,
  Gas:      GasLimit,
  To:       acc2Addr, // 发送到帐户 2
  Value:    big.NewInt(ValueToSend),
  Data:     []byte{},
 }

 // 将其转换为完整的 types.Transaction 对象
 legacyTx := types.NewTx(&tx)

 chainID := big.NewInt(AmoyChainID) // 使用你的链 ID(80002 = Polygon Mumbai Testnet)
 // 注意:尽管这是一个传统 transaction,我们仍然需要使用链 ID 对 transaction 进行签名以实现 EIP-155 兼容性。
 // 因为大多数节点通过要求在签名中包含链 ID 来防止重放攻击。
 // 不防止此攻击的节点将能够获取使用以下命令签名的 transaction:
 // types.SignTx(tx, types.HomesteadSigner{}, priv)
 signedTx, err := types.SignTx(legacyTx, types.NewEIP155Signer(chainID), acc1Priv)
 if err != nil {
  log.Fatal("Failed to sign transaction:", err)
 }

 // 广播 transaction
 err = client.SendTransaction(ctx, signedTx)
 if err != nil {
  log.Fatal("Broadcast failed:", err)
 }

 fmt.Println("Transaction sent!")
 fmt.Println("Tx hash:", signedTx.Hash().Hex())

 // 可选择等待包含
 time.Sleep(10 * time.Second)
 receipt, err := client.TransactionReceipt(ctx, signedTx.Hash())
 if err != nil {
  fmt.Println("Tx not mined yet.")
 } else {
  fmt.Println("Tx mined in block:", receipt.BlockNumber)
 }
}

注意:在 transaction 签名时,我们仍然使用 types.NewEIP155Signer,因为大多数节点会防止重放攻击。不防止此攻击的节点将允许使用 types.SignTx(tx, types.HomesteadSigner{}, priv) 进行签名

挖掘 transaction 后,你可以在 block explorer 中看到它,并看到类似以下内容:

EIP-2930 Transaction:访问列表 Transaction

访问列表 transactions(类型 0x01)在 EIP-2930 中引入,以使某些类型的智能合约调用更可预测和 gas-efficient

这个想法很简单

预先包含你知道你的 transaction 将访问的地址和特定存储槽(keys)列表。

仍然允许不在列表中的访问,但它们会花费更多的 gas 。此外,访问列表 transactions 包含 yParity 参数。此参数的返回值可以是 0x00x1。这是 secp256k1 签名 y 值的奇偶校验(0 表示偶数,1 表示奇数)。

这是与 Ethereum’s Berlin hard fork 一起引入的。

具有 RLP 编码的 Transaction,如下所示:

0x01 || rlp([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS]).
{
  nonce:    "0x0",            // 发送者在此之前进行的 transaction 数量。
  gasPrice: "0x09184e72a000", // 发送者提供的 gas 价格,以 wei 为单位。
  gasLimit: "0x21000",        // 发送者提供的最大 gas 量。
  to:       "0x...",          // 接收者的地址。在合约创建 transactions 中未使用。
  value:    "0x0",            // 传输的值,以 wei 为单位。
  data:     "0x...",          // 用于定义合约创建和交互。
  r:        "0x...",          // ECDSA 签名 r。
  s:        "0x...",          // ECDSA 签名 s。
  y_parity: "0x1"             // secp256k1 签名 y 值的奇偶校验。
  chainId:  "0x...",          // transaction 的 Chain ID。
  accessList: [               // transaction 计划访问的地址和存储 key 列表。
    {
      "address": "0x...",
      "storageKeys": ["..."]
    }
  ],
}

注意:传统 transaction 中的 v 签名组件已演变:从组合恢复和 chain_id,现在是用于纯公钥恢复的 y_parity (0/1),因为 chain_id 已移动到新 transaction 类型中的其自身字段。

AccessList 解释

在 EIP-2930 之前,每次你的 transaction 与智能合约交互或访问当前 transaction 中尚未访问的存储槽时,都会产生“冷”访问成本,即更高的 gas 费用。随后对该 transaction 中_相同_地址或存储槽的访问将是“热的”,这意味着它们的成本更低。

问题是对于复杂的 transactions ,尤其是涉及多个合约调用或大量存储读取/写入的 transactions,这些初始“冷”访问可能会累积起来,从而使 transactions 更加昂贵。

访问列表地址“pre-warms”这些指定的地址。这意味着当 transaction 实际执行时,对这些预先声明的项目的初始访问会产生折扣的前期成本(用于将它们包含在列表中),然后在执行期间花费更低的“热”访问费用。

获取AccessList 地址

访问列表的主要挑战是知道在其中放入什么。复杂的 transaction 可能会访问许多地址和存储槽。手动确定这些几乎是不可能的。

  • 这就是 eth_createAccessList RPC 方法的用武之地。此方法由 Geth 等以太坊客户端提供,可让你模拟 transaction 并返回该 transaction 将生成的确切 accessList。它是准备优化 transactions 的宝贵工具。

例如:假设我们要将 token 转移给某人,我们需要了解将调用哪个存储(你可以使用此 faucet 在 Polygon Amoy 网络上获取 LINK token),我们可以使用此调用:

curl https://polygon-amoy.drpc.org 
  -X POST 
  -H "Content-Type: application/json" 
  -d '{"method": "eth_createAccessList", "params": [{"from": "<your-sending-address>","gas": "0x50000", "gasPrice": "0x6fee2d00","to": "0x0Fd9e8d3aF1aaee056EB9e802c3A762a667b1904", "data": "0xa9059cbb0000000000000000000000008056361b1c1361436D61D187d761233b42d1c20e0000000000000000000000000000000000000000000000000DE0B6B3A7640000"}, "pending"], "id": 1, "jsonrpc": "2.0"}'

响应将是:

{"id":1,"jsonrpc":"2.0","result":{"accessList":[{"address":"0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904","storageKeys":["0xf9a42dc9f268c1720e130da18118febc65e0ca534e035a0e39d30cf8daea5f0a","0x0c2d31ae2b93233fa550fc5df04cd7b0b742c0821a2494f91cd79ca74a9e2e48"]}],"gasUsed":"0xd1a4"}}

注意:在之前的文章 中,我们学习了如何构造数据,此数据表示对 LINK token 上 transfer 方法的调用

意味着这些是在 transfer 调用中将使用的存储地址。

  • 此外,如果我们构建自己的智能合约,我们可以使用 forge cli 工具检查存储,并在已部署的智能合约上使用此 eth_getStorageAt(<slot_num>)

为了获得这一点,我们可以使用下一行:

forge inspect srv/Storage.sol:Storage storage

注意:该存储合约来自之前的博客文章,请确保你已阅读了之前的文章并了解 forge cli 工具。

输出将如下所示:

然后使用 curl 调用 eth_getStorageAt(<slot_num>) 以获取已部署合约的存储地址。

示例

package main

const (
 NodeRPCURL  = "https://polygon-amoy.drpc.org"
 AmoyChainID = 80002   // Polygon Amoy Testnet Chain ID
 ValueToSend = 0.01e18 // 0.01 ETH
)

func main() {
 acc2Addr, acc2Priv := account.GetAccount(2)
 to := lo.ToPtr(common.HexToAddress("0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904"))

 ctx := context.Background()
 client, err := ethclient.Dial(NodeRPCURL)
 if err != nil {
  log.Fatal("Failed to connect to Ethereum node:", err)
 }

 // Nonce
 nonce, err := client.PendingNonceAt(ctx, lo.FromPtr(acc2Addr))
 if err != nil {
  log.Fatal("Failed to fetch nonce:", err)
 }

 // Gas price
 gasPrice, err := client.SuggestGasPrice(ctx)
 if err != nil {
  log.Fatal("Failed to fetch gas price:", err)
 }

 accessList := types.AccessList{
  {
   Address:     common.Address(common.HexToAddress("0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904")),
   StorageKeys: []common.Hash{common.HexToHash("0xf9a42dc9f268c1720e130da18118febc65e0ca534e035a0e39d30cf8daea5f0a"), common.HexToHash("0x0c2d31ae2b93233fa550fc5df04cd7b0b742c0821a2494f91cd79ca74a9e2e48")},
  },
 }

 gasLimit, err := client.EstimateGas(ctx, ethereum.CallMsg{
  From:       *acc2Addr,
  To:         to,
  Data:       common.FromHex("0xa9059cbb0000000000000000000000008056361b1c1361436D61D187d761233b42d1c20e000000000000000000000000000000000000000000000000016345785D8A0000"),
  AccessList: accessList,
 })
 if err != nil {
  log.Fatal("Failed to fetch gas limit:", err)
 }

 chainID := big.NewInt(AmoyChainID) // 使用你的链 ID(80002 = Polygon Mumbai Testnet)

 // 构造 AccessListTx
 txData := types.AccessListTx{
  ChainID:    chainID,
  Nonce:      nonce,
  GasPrice:   gasPrice,
  Gas:        gasLimit,
  To:         to,
  Data:       common.FromHex("0xa9059cbb0000000000000000000000008056361b1c1361436D61D187d761233b42d1c20e000000000000000000000000000000000000000000000000016345785D8A0000"),
  AccessList: accessList,
 }
 tx := types.NewTx(&txData)

 // 签名
 signedTx, err := types.SignTx(tx, types.NewEIP2930Signer(chainID), acc2Priv)
 if err != nil {
  log.Fatal("Failed to sign tx:", err)
 }

 // 广播
 err = client.SendTransaction(ctx, signedTx)
 if err != nil {
  log.Fatal("Broadcast failed:", err)
 }

fmt.Println("Access List Transaction Sent!")
fmt.Println("Tx hash:", signedTx.Hash().Hex())

// Optionally wait for mining
time.Sleep(10 * time.Second)
receipt, err := client.TransactionReceipt(ctx, signedTx.Hash())
if err != nil {
fmt.Println("Tx not mined yet.")
} else {
fmt.Println("Mined in block:", receipt.BlockNumber)
}
}

你将看到类似于:

image.png

:我鼓励你用你的钱包以前没有遇到过的Token地址自己尝试一下,然后在没有访问列表的情况下这样做,看看费用的差异。

EIP-1559交易:动态收费交易

类型为“0x2”的交易在EIP-1559 中引入,在以太坊伦敦硬分叉期间激活。这次升级重新设计了以太坊的收费市场,使交易成本更可预测,并解决了传统“首价拍卖”模式的低效率问题,即用户通过出价“gasPrice”进行竞争,矿工选择支付最高的交易。

EIP-1559 交易不再指定单个 gasPrice,而是使用动态基础费用(dynamic base fee),该费用由协议在每个区块进行调整,具体取决于网络拥塞情况。此基础费用会被销毁(burned),从而从流通中移除 ETH。

交易和 RLP 编码如下所示:

0x02 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s])
{
  nonce:                "0x0",            // 在此之前,发送者发起的交易数量。
  gasLimit:             "0x2710",      // 发送者提供的最大 gas。
  maxPriorityFeePerGas: "0x0",         // 发送者愿意为每个 gas 支付的,高于基础费用的最高费用(以 wei 为单位)。
  maxFeePerGas:         "0x6f4d3132b", // 发送者愿意为每个 gas 支付的最高总费用(基础费用 + 优先级费用)(以 wei 为单位)。
  to:                   "0x...",       // 接收者的地址。在合约创建交易中不使用。
  value:                "0x0",         // 转移的价值(以 wei 为单位)。
  data:                 "0x...",       // 用于定义合约创建和交互。
  yParity:              "0x1"          // secp256k1 签名的 y 值的奇偶性。
  r:                    "0x...",       // ECDSA 签名 r。
  s:                    "0x...",       // ECDSA 签名 s。
  chainId:              "0x...",       // 交易的链 ID。
  accessList:           [],            // 交易计划访问的地址和存储键的列表。
}

新的交易结构和新的费用字段

以太坊现在协议中内置了 每 gas 基础费用(base fee per gas)。此费用会根据前一个区块的拥堵程度,从一个区块自动更改为下一个区块:

  • 如果上一个区块超过target一半,则基础费用上升
  • 如果上一个区块不到target一半,则基础费用下降

此费用有助于调节需求,并且会被销毁(burned)(销毁),而不是支付给验证者。

当用户发送交易时,他们会设置:

  • 小费(tip)priority fee),用于支付给验证者,以换取将他们的交易包含在区块中
  • 最高费用(max fee),这是他们愿意支付的最高总费用

你的交易始终支付其进入的区块的基础费用,并支付小费,只要:

base fee + tip ≤ max fee

否则,交易将被拒绝。

如何设置所需费用

有几种可能的方法来计算 base_fee_per_gas,但我们主要对 eth_feeHistory 方法感兴趣,该方法将使我们清楚地了解最新区块中的费用行为。

curl -X POST https://polygon-amoy.drpc.org 
  -H "Content-Type: application/json" 
  -d '{
    "jsonrpc":"2.0",
    "method":"eth_feeHistory",
    "params": ["0x05", "latest", []],
    "id":1
  }' | jq

注意:第一个参数 0x05 是我们要回顾的区块数,最多可以设置为 1024 个区块。为了便于举例,我们将只查看最新的 5 个区块。

响应将如下所示:

{
  "id": 1,
  "jsonrpc": "2.0",
  "result": {
    "oldestBlock": "0x46d5609",
    "baseFeePerGas": ["0x1205","0x11f3","0x1214","0x1233","0x121b","0x121d"],
    "gasUsedRatio": [0.3687491333333333,0.7324738666666667,0.7207432,0.3332721777777778,0.5188374666666666],
    ....
  }
}

当调用 eth_feeHistory 并指定区块数为 5 时,你将收到:

  • baseFeePerGas 中的 6 个值

前 5 个 对应于最后 5 个区块第 6 个下一个区块的预测基础费用

  • gasUsedRatio 中的 5 个值

每一个值都显示了最近一个区块的拥堵程度(0 = 空,1 = 满)。

从上面的例子我们可以理解:

  • 最近的基础费用徘徊在 0x1200 左右。
  • 最近的区块是部分已满,这解释了为什么下一个基础费用保持相对稳定(0x121d)。
  • 预测的基础费用(最后一个条目)是你的 EIP-1559 交易默认将使用的费用。(取决于你发布交易的时间,始终保持边距是一个好习惯)

例子:

package main

import (
    "context"
    "fmt"
    "log"
    "math/big"
    "time"

    "github.com/ethereum/go-ethereum"
    "github.com/ethereum/go-ethereum/common"
    "github.com/ethereum/go-ethereum/crypto"
    "github.com/ethereum/go-ethereum/ethclient"
    "github.com/ethereum/go-ethereum/lo"
    "github.com/ethereum/go-ethereum/signer/account"
    "github.com/ethereum/go-ethereum/types"
)

const (
 // Polygon Amoy 测试网的公共 RPC URL
 NodeRPCURL  = "https://polygon-amoy.drpc.org"
 AmoyChainID = 80002 // Polygon Amoy 测试网链 ID
)

func main() {
 acc2Addr, acc2Priv := account.GetAccount(2)
 to := lo.ToPtr(common.HexToAddress("0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904"))

 ctx := context.Background()
 client, err := ethclient.Dial(NodeRPCURL)
 if err != nil {
  log.Fatal("Failed to connect to Ethereum node:", err) // 连接以太坊节点失败
 }

 nonce, err := client.PendingNonceAt(ctx, lo.FromPtr(acc2Addr))
 if err != nil {
  log.Fatal("Failed to fetch nonce:", err) // 获取 nonce 失败
 }

 feeHistory, err := client.FeeHistory(ctx, 5, nil, nil)
 if err != nil {
  log.Fatal("Failed to fetch gas price:", err) // 获取 gas 价格失败
 }

 GasTipCap, err := client.SuggestGasTipCap(ctx)
 if err != nil {
  log.Fatal("Failed to fetch gas price:", err) // 获取 gas 价格失败
 }

 // 来自最新区块的基础费用
 latestBaseFee := feeHistory.BaseFee[len(feeHistory.BaseFee)-1]

 // GasFeeCap = baseFee + tip
 // 在最新的基础费用上增加 12% 的缓冲,以避免定价过低
 // 但你也可以使用来自费用历史记录的平均值或中位数。
 bufferedBaseFee := new(big.Int).Mul(latestBaseFee, big.NewInt(112))
 bufferedBaseFee.Div(bufferedBaseFee, big.NewInt(100))

 // 最终 GasFeeCap = bufferedBaseFee + GasTipCap
 GasFeeCap := new(big.Int).Add(bufferedBaseFee, GasTipCap)

 chainID := big.NewInt(AmoyChainID) // 使用你的链 ID(80002 = Polygon Mumbai 测试网)

 gasLimit, err := client.EstimateGas(ctx, ethereum.CallMsg{
  From: *acc2Addr,
  To:   to,
  Data: common.FromHex("0xa9059cbb0000000000000000000000008056361b1c1361436D61D187d761233b42d1c20e000000000000000000000000000000000000000000000000016345785D8A0000"),
 })
 if err != nil {
  log.Fatal("Failed to fetch gas limit:", err) // 获取 gas 限制失败
 }

 tx := types.DynamicFeeTx{
  ChainID:   chainID,
  Nonce:     nonce,
  GasTipCap: GasTipCap,
  GasFeeCap: GasFeeCap,
  Gas:       gasLimit,
  To:        to,
  Data:      common.FromHex("0xa9059cbb0000000000000000000000008056361b1c1361436D61D187d761233b42d1c20e000000000000000000000000000000000000000000000000016345785D8A0000"),
 }

 // 将其转换为完整的 types.Transaction 对象
 eip1559Tx := types.NewTx(&tx)

 signedTx, err := types.SignTx(eip1559Tx, types.LatestSignerForChainID(chainID), acc2Priv)
 if err != nil {
  log.Fatal("Failed to sign transaction:", err) // 签名交易失败
 }

 // 广播交易
 err = client.SendTransaction(ctx, signedTx)
 if err != nil {
  log.Fatal("Broadcast failed:", err) // 广播失败
 }

 fmt.Println("Transaction sent!") // 交易已发送!
 fmt.Println("Tx hash:", signedTx.Hash().Hex()) // Tx 哈希:

 // 可选地等待包含
 time.Sleep(10 * time.Second)
 receipt, err := client.TransactionReceipt(ctx, signedTx.Hash())
 if err != nil {
  fmt.Println("Tx not mined yet.") // Tx 尚未被挖掘。
 } else {
  fmt.Println("Tx mined in block:", receipt.BlockNumber) // Tx 在区块中被挖掘:
 }
}

我们会得到:

我们可以看到我们的估计是完美的,我们的交易已成功挖掘。

EIP-191:签名数据

EIP-191 定义了一个标准,用于使用以太坊账户离线签署任意数据,不是交易,不是智能合约交互,而是普通数据(例如,登录消息、协议、哈希等)。它通常用于身份验证、gasless 工作流程和链下证明。

其思想是为被签名的数据加上前缀,以防止歧义或重放攻击。

**每条EIP-191消息都遵循以下格式:

“\x19” || version || data
  • ' \x19 '是所有消息的常量前缀。
  • ' version '指定消息的类型\ - ' 0x00 '具有预期验证器的数据 - ' 0x01 '结构化数据\ - ' 0x45 '个人标志(我们的案例,其他版本与不同的eip相关)
  • “data”是要签署的实际内容。

为什么0x19

故意选择“0x19”前缀使签名数据无效RLP,从而防止将其误解为常规以太坊交易。由于RLP解码从第一个字节开始,因此像‘ 0x19 ’这样的无效初始字节将导致任何RLP解析器拒绝整个消息,而不管后面是什么。这确保了链下签名消息和链上交易之间的强大分离,保护多签名钱包免受重放或欺骗攻击。

个人签名格式

0x19 <0x45 (E)> \<thereum Signed Message:\n" + len(message)> \<data to sign>

请注意,个人标志的版本是0x45,在ASCII中是E

版本0x45 (E)具有\<thereum Signed Message:\n" + len(message)> 用于特定版本的数据。要签名的数据可以是任意数据。

个人符号中的“hello”消息示例:

"\x19Ethereum Signed Message:\n5hello"

签署流程

  1. dApp像这样散列数据:
hash = keccak256("\x19Ethereum Signed Message:\n" + len(Message) + Message)
  1. 它要求钱包使用用户的私钥对该哈希进行签名。
  2. 在链上,智能合约(例如登录、铸币等)可以从签名中“恢复”签名者。

例子

package main

func main() {
 _, acc2Priv := account.GetAccount(2)

 message := []byte("Login to app.xyz")
 prefixed := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)

 hash := crypto.Keccak256Hash([]byte(prefixed))

 // Sign the hash
 signature, err := crypto.Sign(hash.Bytes(), acc2Priv)
 if err != nil {
  log.Fatal(err)
 }

 fmt.Printf("Message: %s\n", message)
 fmt.Printf("Prefixed Hash: 0x%x\n", hash.Bytes())
 fmt.Printf("Signature: 0x%x\n", signature)

 // Recover the public key
 pubKey, err := crypto.SigToPub(hash.Bytes(), signature)
 if err != nil {
  log.Fatal(err)
 }

 recoveredAddr := crypto.PubkeyToAddress(*pubKey)
 fmt.Printf("Recovered Address: %s\n", recoveredAddr.Hex())
}

输出将是:

Message: Login to app.xyz
Prefixed Hash: 0x9ebab044560303562376f745e565c97c0995cba432397d082cf3260c5e1d6f78
Signature: 0xe89fe57d906e3fa29381c074462c823ecef612485f83cc34ecb6bb511a3da7cf6d82fc7ec21416f1542957e19e154ef00a9bcdd13510af2e911b6e3a6ea3fdd600
Recovered Address: 0xCBAf22b5fA52647af668bb1E895Bb8458028cDE6

恢复的地址正是当前私钥所期望的地址

总结

在这篇文章中,我们从原始的EVM内部开始,开始探索用户和开发人员如何通过交易和消息签名与以太坊链进行实际交互。

我们介绍了并非所有的交易都是平等的。以太坊支持多种交易类型,每种类型都旨在解决不同的问题,从原始的遗留格式到更结构化和动态的类型。

我们还使用Go和“Go -ethereum”库构建和签署真实交易,遍历字段,费用逻辑,甚至如何使用“eth_feeHistory”获取实时网络条件。我们讨论了RLP编码,它是这些交易序列化的基础,并预览了签名消息(如EIP-191和EIP-712)在链下签名数据时是如何发挥作用的。

在下一部分中,我们将继续探索以太坊的其他交易类型,包括EIP-4844 blob交易,并深入研究如何在实际用例中识别、解码和利用它们。

资源

  • 原文链接: medium.com/@andrey_obruc...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
andrey_obruchkov
andrey_obruchkov
江湖只有他的大名,没有他的介绍。