本文将深入探讨如何在不依赖于任何框架的情况下手动发起一笔交易
在开发以太坊的 dapp
(去中心化应用)或交易脚本时,开发者通常会借助某些库或框架来简化与以太坊区块链的交互过程。这些工具提供了便捷的 API 接口,通过这些接口,开发者能够轻松地发送交易、读取链上数据、以及执行其他与区块链交互的操作。虽然这些库或框架能够极大简化交易的创建和发送过程,但其内部的交易构造和发送机制却往往隐藏于开发者的视线之外。
为了解这些框架内部发送交易的原理,本文将深入探讨如何在不依赖于任何框架的情况下手动发起一笔交易。
要在无框架的环境中发送一笔交易,通常需经历以下几个核心步骤:
交易的原始数据结构
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
本质为 HTTPpost
请求,区别在于请求参数为固定的格式,如下所示{ jsonrpc: '2.0', // 指定 JSON-RPC 协议版本 method: '', // 调用的方法名称 params: [], // 调用方法所需要参数 id: 1 // 本次请求的编号 }
响应结果格式如下所示
{ jsonrpc: '2.0', // 指定 JSON-RPC 协议版本 id: 1, // 本次请求的编号, 和请求参数中的 id 一致 result: '' // 请求结果 }
下面将详细介绍交易对象中各个字段
交易的发送者, 必须是 EOA
地址
在以太坊中有 2 种账户:外部账户、合约账户
外部账户:
Externally Owned Accounts
简称EOA
, 拥有私钥, 其codeHash
为空合约账户:
Contact Account
简称CA
, 没有私钥, 其codeHash
非空
交易的接收者, 可以是 EOA
, 也可以是 CA
交易发送者的 nonce
, 值为账户已发送交易数量的计数, 主要有两个方面的作用:
防止双重消费(重放攻击): 在以太坊网络中,每个交易都有一个与之关联的 nonce
值。nonce
是一个只能被使用一次的数字,它能确保每笔交易是独一无二的。通过这种方式,以太坊网络能够防止双重消费攻击,即用户不能使用同一笔资金进行两次或多次交易
交易顺序: 当用户发送新的交易时,该账户的 nonce
值会自增。通过这种机制,以太坊网络能够确保交易按照正确的顺序被处理,即先发送的交易先被处理,后发送的交易后被处理。确保账户状态的正确性和交易的原子性
通过 JSON RPC
方法 eth_getTransactionCount
获取 nonce
值
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
:查询当前挂起区块(尚未被矿工处理的区块)时指定地址的交易计数交易类型, 以太坊中存在三种交易类型,有以下取值:
EIP-2718
之前的交易, 交易字段有from, to, type, value, data, nonce, gas, gasPrice
EIP-2930
, 新增字段 accessList
from, to, type, value, data, nonce, gas, gasPrice, accessList
EIP-1559
, 移除了 gasPrice
, 新增 maxPriorityFeePerGas
和 maxFeePerGas
from, to, type, value, data, nonce, gas, maxPriorityFeePerGas, maxFeePerGas, accessList
交易携带的 ETH
数量, 单位是 WEI
($1\tt{ETH}=10^{18}\tt{WEI}$)
交易携带的数据, 如果是转账交易, 该字段可为空。如果是调用合约的交易, data
则为合约函数的选择器哈希值拼接上函数参数编码
每单位 Gas
的优先价格,仅 type
为 2 时提供,这部分的费用将支付给矿工。
通过 JSON RPC
方法 eth_maxPriorityFeePerGas
获取当前最新的 maxPriorityFeePerGas
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
maxPriorityFeePerGas = gasPrice - block.baseFeePerGas
每单位 Gas
的最大价格,仅 type
为 2 时提供。该字段的目的是为了防止因 gasPrice
波动而导致交易被剔除出打包序列。通常计算公式为 baseFeePerGas
乘以一个倍数 multiple
再加上 maxPriorityFeePerGas
maxFeePerGas = block.baseFeePerGas * multiple + maxPriorityFeePerGas
当 multiple
为 2 时, 可以保证连续 6 个区块满 Gas
的情况下仍在内存池中等待打包。
在不同的框架中 multiple
被设置成不同的值, 在 viem 中值为 1.2, 在 ethers.js 中值为 2
该字段意为该交易最多可花费的 gas
数量, 即 gasLimit
。转账交易时, 该值固定为 21000
通过 JSON RPC
方法 eth_estimateGas
可获取交易预估值作为该字段值
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
编码算法, 根据交易类型的不同,遵循下列公式
RLP.encode([nonce, gasPrice, gasLimit, to, value, data, v, r, s])
0x01 || RLP.encode([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])
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
]对于交易
{
form: "0x2557D0d204a51CF37A0474b814Afa6f942f522cc",
to: "0x87114ed56659216E7a1493F2Bdb870b2f2102156",
nonce: "0x9",
type: "0x2",
value: "0x2386f26fc10000",
maxPriorityFeePerGas: "0x3e6",
maxFeePerGas: "0x482",
gas: "0x5208"
}
在 goerli
网络上序列化后的结果为
const serializedTransaction = [
'0x5', // chainId
'0x9', // nonce
'0x3e6', // maxPriorityFeePerGas
'0x482', // maxFeePerGas
'0x5208', // gas
'0x87114ed56659216E7a1493F2Bdb870b2f2102156', // to
'0x2386f26fc10000', // value
'0x', // data
[] // accessList
]
将序列化的结果进行 RLP
编码, 得到 Uint8
类型的字节数组, 同时将交易类型加入到数组的第一个元素
import RLP from 'rlp'
const toRlp = (serializedTransaction) => {
// 交易类型加入到数组第一个元素
return new Uint8Array([2, ...RLP.encode(serializedTransaction)])
}
const rlp = toRlp(serializedTransaction)
按照上述公式, 如果 type = 0
, 则无需将交易类型加入数组
最后对 RLP
编码结果应用 keccak_256
哈希函数, 生成 32 字节的哈希值
import { keccak_256 } from '@noble/hashes/sha3'
const hash = toHex(keccak_256(rlp))
将哈希结果使用私钥签名
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
编码
serializedTransaction.push(
signature.v === 27n ? '0x' : toHex(1), // yParity
r,
s
)
const lastRlp = toRlp(serializedTransaction)
得到最终结果 lastRlp
是一个 Uint8
类型的字节数组, 每个元素占用 1 个字节,范围在 0 - 255。值表示为在长度为 256 按顺序构成的 16 进制数组中的索引
// 将数字从 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
发送到节点
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
获取交易回执
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
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!