如何手动构造以太坊交易并发送

在开发以太坊的 dapp（去中心化应用）或交易脚本时，开发者通常会借助某些库或框架来简化与以太坊区块链的交互过程。这些工具提供了便捷的 API 接口，通过这些接口，开发者能够轻松地发送交易、读取链上数据、以及执行其他与区块链交互的操作。虽然这些库或框架能够极大简化交易的创建和发送过程，但其内部的交易构造和发送机制却往往隐藏于开发者的视线之外。

为了解这些框架内部发送交易的原理，本文将深入探讨如何在不依赖于任何框架的情况下手动发起一笔交易。

要在无框架的环境中发送一笔交易，通常需经历以下几个核心步骤：

构建交易对象：创建一个包含了交易相关信息（如交易的发送方、接收方、金额、Gas 价格等）的交易对象

对交易对象进行签名：利用私钥对构建好的交易对象进行签名，以确保交易的安全性和完整性

发送交易：将签名后的交易对象发送到以太坊网络

构造交易

交易的原始数据结构

Copy
interface Transaction {
form: Address // 交易的发送者
to: Address // 交易的接收者
nonce: Hex // 发送者的nonce
type: Hex // 交易类型, 0(legcy) 或 1(EIP-2930) 或 2(EIP-1559)
value: Hex // 交易携带的主币数量, 单位是 wei
data: Hex // 交易携带的数据
maxPriorityFeePerGas?: Hex // EIP-1559:每单位 gas 优先费用, type=2时提供
maxFeePerGas?: Hex // EIP-1559:每单位 gas 最大费用, type=2时提供
gas: Hex // 可使用的最大 gas 数量(gasLimit)
gasPrice?: Hex // gas 价格, type!=2时提供
accessList？: [] // EIP-2930新增属性, 值为包含地址和存储键的列表，主要为解决EIP-2929带来的副作用问题
}

其中相关字段需要通过 JSON RPC 获取

JSON RPC 本质为 HTTP post 请求，区别在于请求参数为固定的格式，如下所示

Copy
{
jsonrpc: '2.0', // 指定 JSON-RPC 协议版本
method: '', // 调用的方法名称
params: [], // 调用方法所需要参数
id: 1 // 本次请求的编号
}

响应结果格式如下所示

Copy
{
jsonrpc: '2.0', // 指定 JSON-RPC 协议版本
id: 1, // 本次请求的编号, 和请求参数中的 id 一致
result: '' // 请求结果
}

下面将详细介绍交易对象中各个字段

from

交易的发送者，必须是 EOA 地址

在以太坊中有 2 种账户：外部账户、合约账户

外部账户: Externally Owned Accounts 简称 EOA, 拥有私钥，其 codeHash 为空

合约账户: Contact Account 简称 CA, 没有私钥，其 codeHash 非空

to

交易的接收者，可以是 EOA, 也可以是 CA

nonce

交易发送者的 nonce, 值为账户已发送交易数量的计数，主要有两个方面的作用:

防止双重消费（重放攻击）: 在以太坊网络中，每个交易都有一个与之关联的 nonce 值。nonce 是一个只能被使用一次的数字，它能确保每笔交易是独一无二的。通过这种方式，以太坊网络能够防止双重消费攻击，即用户不能使用同一笔资金进行两次或多次交易

交易顺序：当用户发送新的交易时，该账户的 nonce 值会自增。通过这种机制，以太坊网络能够确保交易按照正确的顺序被处理，即先发送的交易先被处理，后发送的交易后被处理。确保账户状态的正确性和交易的原子性

通过 JSON RPC 方法 eth_getTransactionCount 获取 nonce 值

Copy
import axios from 'axios'

const rpc_url = 'https://rpc.ankr.com/eth_goerli'

const getNonce = async () => {
const response = await axios.post(rpc_url, {
jsonrpc: '2.0',
method: 'eth_getTransactionCount',
params: [account.address, 'pending'],
id: 1
})
return response.data.result
}

eth_getTransactionCount 方法有以下两个参数:

address: 账户地址

blockNumber: 区块编号，可以是一个十六进制的区块高度值，或是 latest、earliest、pending中的一个

特定的区块号：查询该区块指定地址的交易计数

latest： 查询最新区块时指定地址的交易计数

earliest：查询创世区块（第一个区块）时指定地址的交易计数

pending：查询当前挂起区块（尚未被矿工处理的区块）时指定地址的交易计数

type

交易类型，以太坊中存在三种交易类型，有以下取值:

0: legcy, EIP-2718 之前的交易，交易字段有

Copy
from, to, type, value, data, nonce, gas, gasPrice

1: EIP-2930, 新增字段 accessList

Copy
from, to, type, value, data, nonce, gas, gasPrice, accessList

2: EIP-1559, 移除了 gasPrice, 新增 maxPriorityFeePerGas 和 maxFeePerGas

Copy
from, to, type, value, data, nonce, gas, maxPriorityFeePerGas, maxFeePerGas, accessList

value

交易携带的 ETH 数量，单位是 WEI($1\tt{ETH}=10^{18}\tt{WEI}$)

data

交易携带的数据，如果是转账交易，该字段可为空。如果是调用合约的交易，data 则为合约函数的选择器哈希值拼接上函数参数编码

maxPriorityFeePerGas

每单位 Gas 的优先价格，仅 type 为 2 时提供，这部分的费用将支付给矿工。

通过 JSON RPC 方法 eth_maxPriorityFeePerGas 获取当前最新的 maxPriorityFeePerGas

Copy
const getMaxPriorityFeePerGas = async () => {
const response = await axios.post(rpc_url, {
jsonrpc: '2.0',
method: 'eth_maxPriorityFeePerGas',
params: [],
id: 1
})
return response.data.result
}

该方法不是标准方法，通常由第三方节点服务商 (alchemy, infura 等) 提供，如果你使用的节点不存在该方法。则可以尝试下面的备选方案

通过 JSON RPC 方法 eth_gasPrice 获取当前最新的 gasPrice

通过 JSON RPC 方法 eth_getBlockByNumber 获取当前最新的区块信息 block, 区块信息中存在 baseFeePerGas

将两者相减可以获得 maxPriorityFeePerGas

Copy
maxPriorityFeePerGas = gasPrice - block.baseFeePerGas

maxFeePerGas

每单位 Gas 的最大价格，仅 type 为 2 时提供。该字段的目的是为了防止因 gasPrice 波动而导致交易被剔除出打包序列。通常计算公式为 baseFeePerGas 乘以一个倍数 multiple 再加上 maxPriorityFeePerGas

Copy
maxFeePerGas = block.baseFeePerGas * multiple + maxPriorityFeePerGas

当 multiple 为 2 时，可以保证连续 6 个区块满 Gas 的情况下仍在内存池中等待打包。

在不同的框架中 multiple 被设置成不同的值，在 viem 中值为 1.2， 在 ethers.js 中值为 2

gas

该字段意为该交易最多可花费的 gas 数量，即 gasLimit。转账交易时，该值固定为 21000

通过 JSON RPC 方法 eth_estimateGas 可获取交易预估值作为该字段值

Copy
const estimateGas = async (originTransaction) => {
const response = await axios.post(rpc_url, {
jsonrpc: '2.0',
method: 'eth_estimateGas',
params: [originTransaction],
id: 1
})
return response.data.result
}

const originTransaction = {
form: '0x...',
to: '0x...',
nonce: '0x...',
type: '0x2',
value: '0x2386f26fc10000',
maxPriorityFeePerGas: '0x3f7',
maxFeePerGas: '0x42a'
}
originTransaction.gas = await estimateGas(originTransaction)

签名

为了确保交易是由私钥持有者发出的，还需要使用私钥对交易进行签名。签名前需要先经过序列化、编码过程

交易编码采用 RLP 编码算法，根据交易类型的不同，遵循下列公式

legacy, type = 0

Copy
RLP.encode([nonce, gasPrice, gasLimit, to, value, data, v, r, s])

EIP-2930, type = 1

Copy
0x01 || RLP.encode([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])

EIP-1559, type = 2

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

序列化

序列化的过程本质是将交易对象中的字段按照一定的顺序排列

对于不同交易类型，按照上述公式存在不同的顺序。(未签名时，最后三个私钥签名字段可为空)

type=0: 顺序为 [nonce, gasPrice, gas, to, value, data]

type=1: 顺序为 [chainId, nonce, gasPrice, gas, to, value, data, accessList]

type=2: 顺序为 [chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gas, to, value, data, accessList]

对于交易

Copy
{
form: "0x2557D0d204a51CF37A0474b814Afa6f942f522cc",
to: "0x87114ed56659216E7a1493F2Bdb870b2f2102156",
nonce: "0x9",
type: "0x2",
value: "0x2386f26fc10000",
maxPriorityFeePerGas: "0x3e6",
maxFeePerGas: "0x482",
gas: "0x5208"
}

在 goerli 网络上序列化后的结果为

Copy
const serializedTransaction = [
'0x5', // chainId
'0x9', // nonce
'0x3e6', // maxPriorityFeePerGas
'0x482', // maxFeePerGas
'0x5208', // gas
'0x87114ed56659216E7a1493F2Bdb870b2f2102156', // to
'0x2386f26fc10000', // value
'0x', // data
[] // accessList
]

编码

将序列化的结果进行 RLP 编码，得到 Uint8 类型的字节数组，同时将交易类型加入到数组的第一个元素

Copy
import RLP from 'rlp'
const toRlp = (serializedTransaction) => {
// 交易类型加入到数组第一个元素
return new Uint8Array([2, ...RLP.encode(serializedTransaction)])
}
const rlp = toRlp(serializedTransaction)

按照上述公式，如果 type = 0, 则无需将交易类型加入数组

最后对 RLP 编码结果应用 keccak_256 哈希函数，生成 32 字节的哈希值

Copy
import { keccak_256 } from '@noble/hashes/sha3'
const hash = toHex(keccak_256(rlp))

secp256k1 加密

将哈希结果使用私钥签名

Copy
import { secp256k1 } from '@noble/curves/secp256k1'

const { r, s, recovery } = secp256k1.sign(hash.slice(2), privateKey.slice(2))
return {
r: toHex(r),
s: toHex(s),
v: recovery ? 28n : 27n
}

得到签名结果 r、s、v 后，按照公式重新将其加入到序列化数组中，并重新进行 RLP 编码

Copy
serializedTransaction.push(
signature.v === 27n ? '0x' : toHex(1), // yParity
r,
s
)
const lastRlp = toRlp(serializedTransaction)

得到最终结果 lastRlp 是一个 Uint8 类型的字节数组，每个元素占用 1 个字节，范围在 0 - 255。值表示为在长度为 256 按顺序构成的 16 进制数组中的索引

Copy
// 将数字从 0 到 255 转成 16 进制, 并存储数组中
// [ "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "0a", "0b", "0c", "0d", "0e", ...]
const hexes = Array.from({ length: 256 }, (_v, i) =>
i.toString(16).padStart(2, '0')
)

// 遍历 lastRlp 数组, 将数组元素存储的索引值，在 hexes 找到对应的值进行拼接
const signedTransaction =
'0x' +
lastRlp.reduce((prev, current) => {
return prev + hexes[current]
}, '')

最后得到签名后的交易 signedTransaction，为一个 16 进制的字符串

发送交易

通过 JSON RPC 方法 eth_sendRawTransaction, 将 signedTransaction 发送到节点

Copy
const sendRawTransaction = async (signedTransaction) => {
const response = await axios.post(rpc_url, {
jsonrpc: '2.0',
method: 'eth_sendRawTransaction',
params: [signedTransaction],
id: 1
})
return response.data.result
}

eth_sendRawTransaction 方法将返回交易哈希

获取交易回执

发送交易后，为了确保交易完成。可轮询调用 JSON RPC 方法 eth_getTransactionReceipt 获取交易回执

Copy
const getTransactionReceipt = async (hash: string) => {
const response = await axios.post(rpc_url, {
jsonrpc: '2.0',
method: 'eth_getTransactionReceipt',
params: [hash],
id: 1
})
return response.data.result
}

const interval = setInterval(async () => {
const receipt = await getTransactionReceipt(hash)
console.log(receipt)

if (receipt && receipt.blockNumber) clearInterval(interval)
}, 4000)

eth_getTransactionReceipt 接收参数为交易哈希

完整代码见 Github