交易构造与生命周期
概述
交易(Transaction)是以太坊状态变更的唯一方式。无论是转账 ETH、调用合约函数还是部署合约,都必须通过交易来完成。理解交易的构造、签名、广播、确认整个生命周期,是 DApp 前端开发的核心能力。
本文将深入讲解交易类型、Gas 机制、Nonce 管理、交易加速与取消等关键知识点,并结合前端代码实现完整的交易流程。
一笔交易包含哪些字段? EIP-1559 之后主流是 Type 2 交易,前端要理解的字段:
- to / value / data:转给谁、转多少 ETH、合约调用的 calldata。
- nonce:账户已发交易的计数,必须严格连续递增,错乱会卡住后续交易。
- maxFeePerGas + maxPriorityFeePerGas:愿意付的最高单价 + 给矿工的小费,实际付费 =
min(baseFee + tip, maxFee)。 - gasLimit:本次最多消耗的 Gas 数;估算用
eth_estimateGas,再乘 1.2 留余量。
交易生命周期是怎样的?
- 钱包 签名 → 通过 RPC
eth_sendRawTransaction进入 mempool → 被打包进 区块 → 等待 N 个确认才算最终。 - 前端拿到 hash 后用
waitForTransactionReceipt轮询,状态从 pending → success/reverted。 - 如果 baseFee 飙升交易卡住,可以发 同 nonce、更高 Gas 的「替代交易」加速;发到自己地址的 0 ETH 交易则用于「取消」。
Gas 是怎么算的?
transactionFee = gasUsed × effectiveGasPrice,单位是 Gwei;effectiveGasPrice 在 EIP-1559 下 =baseFee + priorityFee。- baseFee 由网络拥堵动态调整,每个区块按上一个区块利用率 ±12.5%。
- 简单转账固定 21000 gas,复杂合约调用可能几十万到几百万。
交易类型
以太坊经过多次升级,目前支持三种交易类型:
| 类型 | EIP | Type 值 | 特点 | 引入时间 |
|---|---|---|---|---|
| Legacy | - | 0 | 原始交易格式,使用 gasPrice | 创世 |
| Access List | EIP-2930 | 1 | 引入访问列表,预声明访问的地址和存储槽 | 柏林升级(2021.4) |
| Dynamic Fee | EIP-1559 | 2 | 引入 maxFeePerGas + maxPriorityFeePerGas,Gas 费更可预测 | 伦敦升级(2021.8) |
目前绑大多数场景都应使用 Type 2(EIP-1559) 交易。钱包(如 MetaMask)默认也会构造 Type 2 交易。Legacy 交易仍然兼容,但 Gas 费估算不够精确。
交易字段详解
以 EIP-1559(Type 2)交易为例,核心字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
chainId | number | 链 ID(如以太坊主网为 1) |
nonce | number | 发送者的交易序号,从 0 开始递增 |
to | address | 接收地址,部署合约时为 null |
value | bigint | 转账金额(单位:Wei) |
data | bytes | 调用数据(calldata),纯转账时为 0x |
gasLimit | bigint | 最大 Gas 用量 |
maxFeePerGas | bigint | 每单位 Gas 的最大费用(包含 Base Fee + Priority Fee) |
maxPriorityFeePerGas | bigint | 每单位 Gas 的小费(直接给验证者) |
accessList | array | 预声明的访问列表(可选) |
nonce 必须严格连续。如果当前 nonce 为 5,则必须先发送 nonce=5 的交易,才能发送 nonce=6 的交易。nonce 不连续会导致后续交易被卡在 mempool 中。
EIP-1559 Gas 机制深入
EIP-1559 彻底改变了以太坊的 Gas 费模型,引入了三个关键概念:
Base Fee(基础费用)
- 由协议自动调整,不归任何人所有,直接销毁
- 根据上一个区块的 Gas 使用率动态调整:
- 区块 Gas 使用率
> 50%:Base Fee 上涨(最多 +12.5%) - 区块 Gas 使用率
< 50%:Base Fee 下降(最多 -12.5%)
- 区块 Gas 使用率
- 同一区块内所有交易的 Base Fee 相同
Priority Fee(优先费 / 小费)
- 用户设置的小费,直接支付给验证者
- 验证者倾向于选择 Priority Fee 更高的交易优先打包
- 对应字段:
maxPriorityFeePerGas
Max Fee(最大费用)
- 用户愿意为每单位 Gas 支付的最大总费用
- 对应字段:
maxFeePerGas - 必须满足:
maxFeePerGas >= baseFee + maxPriorityFeePerGas
实际费用计算
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
实际总费用 = effectiveGasPrice × gasUsed
多付的费用会自动退还给用户:
退还金额 = (maxFeePerGas - effectiveGasPrice) × gasUsed
示例:假设 baseFee = 20 Gwei,用户设置 maxFeePerGas = 50 Gwei,maxPriorityFeePerGas = 2 Gwei:
effectiveGasPrice = min(50, 20 + 2) = 22 Gwei
退还 = (50 - 22) × gasUsed = 28 Gwei × gasUsed
交易生命周期
一笔交易从构造到最终确认,经历以下阶段:
各阶段详解
- 构造交易:DApp 组装 calldata(
encodeFunctionData)、设定 Gas 参数 - Gas 估算:调用
estimateGas模拟执行,获取预估 Gas 用量 - 签名:钱包使用用户私钥对交易数据进行 ECDSA 签名
- 广播:签名后的交易发送到节点,进入 mempool(交易池)
- 打包:验证者从 mempool 中选取交易(优先选择 Priority Fee 高的),构建区块
- 确认:交易被包含在区块中,获得第一个确认
- 最终性:在 PoS 以太坊中,经过 2 个 epoch(约 12.8 分钟)达到最终性(finality),交易不可逆转
前端交易流程实现
构造交易数据
import { encodeFunctionData, parseAbi, parseEther } from 'viem';
// 定义合约 ABI(仅包含需要调用的函数)
const abi = parseAbi([
'function transfer(address to, uint256 amount) returns (bool)',
]);
// 编码函数调用数据
const data = encodeFunctionData({
abi,
functionName: 'transfer',
args: ['0xRecipientAddress...', parseEther('100')],
});
console.log(data);
// 输出类似:0xa9059cbb000000000000000000000000...
// 前 4 字节是函数选择器,后面是 ABI 编码的参数
发送交易与等待确认
import { createWalletClient, createPublicClient, http, parseEther } from 'viem';
import { mainnet } from 'viem/chains';
const publicClient = createPublicClient({
chain: mainnet,
transport: http(),
});
// 发送原生 ETH 转账
async function sendETH(walletClient: WalletClient) {
// 1. 发送交易,返回交易哈希
const txHash = await walletClient.sendTransaction({
to: '0xRecipientAddress...',
value: parseEther('0.1'),
});
console.log('交易已广播,哈希:', txHash);
// 此时交易状态为 pending
// 2. 等待交易被确认(默认等待 1 个区块确认)
const receipt = await publicClient.waitForTransactionReceipt({
hash: txHash,
confirmations: 1, // 等待 1 个区块确认
});
// 3. 检查交易状态
if (receipt.status === 'success') {
console.log('交易成功!区块号:', receipt.blockNumber);
console.log('实际 Gas 消耗:', receipt.gasUsed);
console.log('实际 Gas 价格:', receipt.effectiveGasPrice);
} else {
console.error('交易失败(revert)');
}
}
合约写操作
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
function TransferButton() {
const { data: hash, writeContract, isPending } = useWriteContract();
// 监听交易确认
const { isLoading: isConfirming, isSuccess } =
useWaitForTransactionReceipt({ hash });
async function handleTransfer() {
writeContract({
address: '0xTokenContract...',
abi: erc20Abi,
functionName: 'transfer',
args: ['0xRecipient...', parseEther('100')],
});
}
return (
<div>
<button onClick={handleTransfer} disabled={isPending}>
{isPending ? '等待钱包确认...' : '转账'}
</button>
{isConfirming && <p>交易确认中...</p>}
{isSuccess && <p>交易成功!</p>}
</div>
);
}
交易状态管理
交易在前端的完整状态流转:
type TxStatus = 'idle' | 'pending' | 'submitted' | 'confirmed' | 'failed' | 'rejected';
async function executeTransaction(
walletClient: WalletClient,
publicClient: PublicClient,
txRequest: TransactionRequest,
onStatusChange: (status: TxStatus, data?: any) => void,
) {
try {
onStatusChange('pending');
// 发送交易(此时钱包弹出确认窗口)
const hash = await walletClient.sendTransaction(txRequest);
onStatusChange('submitted', { hash });
// 等待链上确认
const receipt = await publicClient.waitForTransactionReceipt({
hash,
confirmations: 1,
});
if (receipt.status === 'success') {
onStatusChange('confirmed', { receipt });
} else {
onStatusChange('failed', { receipt });
}
} catch (error: any) {
// 用户在钱包中点击了"拒绝"
if (error.code === 4001) {
onStatusChange('rejected');
} else {
onStatusChange('failed', { error });
}
}
}
Nonce 管理
基本规则
- 每个地址维护一个 nonce 计数器,从 0 开始
- 每发送一笔交易,nonce +1
- 交易必须按 nonce 顺序执行
获取当前 Nonce
// 获取下一个可用的 nonce(包含 pending 状态的交易)
const nonce = await publicClient.getTransactionCount({
address: '0xYourAddress...',
blockTag: 'pending', // 包含 mempool 中的交易
});
连续发送多笔交易
async function sendBatchTransactions(
walletClient: WalletClient,
transactions: TransactionRequest[],
) {
// 获取起始 nonce
let nonce = await publicClient.getTransactionCount({
address: walletClient.account.address,
blockTag: 'pending',
});
const hashes: string[] = [];
for (const tx of transactions) {
// 为每笔交易指定递增的 nonce
const hash = await walletClient.sendTransaction({
...tx,
nonce: nonce++,
});
hashes.push(hash);
}
return hashes;
}
如果两笔交易使用了相同的 nonce,只有一笔会被打包(通常是 Gas 价格更高的那笔)。另一笔会被丢弃。这也是"交易加速"和"交易取消"的原理。
交易加速与取消
当交易因 Gas 费过低而长时间 pending 时,可以通过发送相同 nonce 但更高 Gas 费的新交易来替换它。
加速交易
async function speedUpTransaction(
walletClient: WalletClient,
originalTx: {
nonce: number;
to: string;
value: bigint;
data: string;
},
) {
// 获取当前网络 Gas 价格
const gasPrice = await publicClient.estimateFeesPerGas();
// 用更高的 Gas 费重新发送(至少提高 10% 才能被接受)
const hash = await walletClient.sendTransaction({
...originalTx,
nonce: originalTx.nonce, // 使用相同 nonce
maxFeePerGas: (gasPrice.maxFeePerGas * 120n) / 100n, // 提高 20%
maxPriorityFeePerGas: (gasPrice.maxPriorityFeePerGas * 120n) / 100n,
});
return hash;
}
取消交易
async function cancelTransaction(
walletClient: WalletClient,
nonce: number,
) {
// 发送一笔 value=0、data=0x 的交易给自己,使用相同 nonce
// 相当于用一笔"空交易"替换原交易
const hash = await walletClient.sendTransaction({
to: walletClient.account.address, // 发给自己
value: 0n,
data: '0x',
nonce,
maxFeePerGas: (await publicClient.estimateFeesPerGas()).maxFeePerGas * 2n,
maxPriorityFeePerGas: 10_000_000_000n, // 给足够高的小费确保被打包
});
return hash;
}
节点通常要求替换交易的 maxPriorityFeePerGas 至少比原交易高 10%,否则会被拒绝(underpriced 错误)。
交易回执(Receipt)解析
交易被打包后,可以获取交易回执,包含执行结果的详细信息:
| 字段 | 说明 |
|---|---|
status | 执行状态:'success' 或 'reverted' |
blockNumber | 被包含的区块号 |
blockHash | 区块哈希 |
transactionHash | 交易哈希 |
gasUsed | 实际消耗的 Gas 量 |
effectiveGasPrice | 实际的 Gas 单价 |
logs | 交易执行过程中触发的事件日志 |
contractAddress | 如果是部署合约,返回新合约地址 |
import { decodeEventLog, parseAbi } from 'viem';
const receipt = await publicClient.getTransactionReceipt({ hash: txHash });
// 计算实际花费的 ETH
const totalCost = receipt.gasUsed * receipt.effectiveGasPrice;
console.log('Gas 花费:', formatEther(totalCost), 'ETH');
// 解析事件日志(例如 ERC-20 Transfer 事件)
const transferAbi = parseAbi([
'event Transfer(address indexed from, address indexed to, uint256 value)',
]);
for (const log of receipt.logs) {
try {
const event = decodeEventLog({
abi: transferAbi,
data: log.data,
topics: log.topics,
});
console.log(
`Transfer: ${event.args.from} → ${event.args.to}, 金额: ${event.args.value}`,
);
} catch {
// 不匹配的日志跳过
}
}
常见面试问题
Q1: EIP-1559 交易的实际 Gas 费如何计算?
答案:
实际每单位 Gas 的价格为:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
其中 baseFee 由协议根据上一个区块的 Gas 使用率自动调整,maxPriorityFeePerGas 是给验证者的小费,maxFeePerGas 是用户设定的上限。总费用 = effectiveGasPrice × gasUsed,多付的部分自动退还。
Q2: Base Fee 是如何调整的?
答案:
以太坊每个区块有一个目标 Gas 用量(Gas limit 的 50%)。如果实际用量超过 50%,Base Fee 上涨(最多 +12.5%);低于 50% 则下降(最多 -12.5%)。Base Fee 不归验证者所有,而是被直接销毁(burn),这也是 ETH 通缩的来源之一。
Q3: 什么是 Nonce?为什么需要它?
答案:
Nonce 是每个地址的交易序号计数器,从 0 开始,每发送一笔交易递增 1。它有两个核心作用:
- 防止重放攻击:同一个签名的交易不能被重复执行
- 保证交易顺序:节点按 nonce 顺序处理交易
如果 nonce 不连续(比如跳过了 nonce=5 直接发 nonce=6),后面的交易会在 mempool 中等待,直到 nonce=5 的交易被执行或超时。
Q4: 如何加速或取消一笔 pending 中的交易?
答案:
核心原理是用相同 nonce 发送新交易:
- 加速:使用相同 nonce,相同的 to/value/data,但提高 Gas 费(至少比原交易高 10%)
- 取消:使用相同 nonce,发送一笔给自己的空交易(
value=0, data=0x),Gas 费设高确保被优先打包
节点会替换 mempool 中相同 nonce 的旧交易。最终只有一笔会被打包。
Q5: estimateGas 的返回值能直接用吗?
答案:
estimateGas 返回的是模拟执行的 Gas 消耗量,但实际执行时可能因为状态变化而有差异。建议在估算值基础上增加一定的缓冲:
const estimated = await publicClient.estimateGas(txRequest);
// 增加 20% 缓冲,避免 out of gas
const gasLimit = (estimated * 120n) / 100n;
多设的 gasLimit 不会多收费,只是设定上限。实际只按 gasUsed 收费。
Q6: 交易的 data 字段是什么?
答案:
data 字段包含合约调用的 calldata,由两部分组成:
- 前 4 字节:函数选择器(函数签名的 keccak256 哈希的前 4 字节)
- 后面部分:ABI 编码的参数
例如 transfer(address,uint256) 的选择器是 0xa9059cbb。纯 ETH 转账时 data 为 0x。部署合约时 data 包含合约字节码 + 构造函数参数。
Q7: 交易失败(revert)Gas 费会退还吗?
答案:
不会。交易 revert 后,receipt.status 为 'reverted',但 Gas 费已经被消耗。因为验证者确实执行了计算工作(直到遇到 revert),所以 Gas 不退还。这就是为什么前端需要:
- 在发送交易前通过
estimateGas模拟执行,提前发现 revert - 如果
estimateGas报错,提示用户交易可能失败
Q8: 如何在前端处理交易的不同状态?
答案:
前端需要跟踪交易的完整生命周期:
- pending:用户在钱包确认中 → 显示"等待确认"
- submitted:已广播到网络 → 显示交易哈希和区块浏览器链接
- confirming:等待区块确认 → 显示"交易确认中"
- confirmed:交易成功 → 显示成功提示,刷新数据
- failed:交易 revert → 显示错误信息
- rejected:用户在钱包中拒绝 → 重置状态
推荐使用 wagmi 的 useWaitForTransactionReceipt 来监听确认状态。
Q9: Legacy 交易和 EIP-1559 交易的主要区别是什么?
答案:
| 维度 | Legacy(Type 0) | EIP-1559(Type 2) |
|---|---|---|
| Gas 价格设置 | 单一的 gasPrice | maxFeePerGas + maxPriorityFeePerGas |
| 费用可预测性 | 低,容易多付 | 高,Base Fee 透明可查 |
| 费用退还 | 无退还,按 gasPrice 全额收取 | 多付部分自动退还 |
| ETH 销毁 | 无 | Base Fee 部分被销毁 |
Q10: 什么是交易的最终性(Finality)?
答案:
最终性指交易不可逆转的保证程度:
- PoW 时代:没有绝对最终性,随着确认数增加安全性提高(通常 12 个区块约 3 分钟)
- PoS 时代(当前):以太坊 PoS 提供经济最终性,经过 2 个 epoch(约 12.8 分钟)后,回滚交易需要销毁至少 1/3 的质押 ETH(价值数十亿美元),几乎不可能发生
对于前端应用,通常 1-3 个区块确认就足够了。对于高价值操作(如大额转账),建议等待更多确认。
Q11: 发送交易时为什么需要连接钱包?能否用前端直接签名?
答案:
交易签名需要私钥,而私钥必须安全保管。钱包(MetaMask 等)的核心价值就是安全管理私钥并执行签名。前端 DApp 不应该也不能接触用户的私钥。整个流程:
- DApp 构造未签名的交易数据
- 通过钱包 API(
eth_sendTransaction)请求签名 - 钱包弹窗让用户确认,内部用私钥签名
- 签名后的交易广播到网络
在服务端脚本中可以用 privateKeyToAccount 直接签名,但在浏览器前端绝不应如此操作。
Q12: 交易回执中的 logs 有什么用?
答案:
logs 是交易执行过程中合约通过 emit 触发的事件记录。每个 log 包含:
address:触发事件的合约地址topics:事件签名哈希 + indexed 参数(最多 4 个 topic)data:非 indexed 参数的 ABI 编码
前端可以用 decodeEventLog 解析这些日志,获取交易的详细执行信息。例如 ERC-20 转账后解析 Transfer 事件,确认转账的发送方、接收方和金额。