跳到主要内容

交易构造与生命周期

概述

交易(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,复杂合约调用可能几十万到几百万。

交易类型

以太坊经过多次升级,目前支持三种交易类型:

类型EIPType 值特点引入时间
Legacy-0原始交易格式,使用 gasPrice创世
Access ListEIP-29301引入访问列表,预声明访问的地址和存储槽柏林升级(2021.4)
Dynamic FeeEIP-15592引入 maxFeePerGas + maxPriorityFeePerGas,Gas 费更可预测伦敦升级(2021.8)
实际开发建议

目前绑大多数场景都应使用 Type 2(EIP-1559) 交易。钱包(如 MetaMask)默认也会构造 Type 2 交易。Legacy 交易仍然兼容,但 Gas 费估算不够精确。

交易字段详解

以 EIP-1559(Type 2)交易为例,核心字段如下:

字段类型说明
chainIdnumber链 ID(如以太坊主网为 1)
noncenumber发送者的交易序号,从 0 开始递增
toaddress接收地址,部署合约时为 null
valuebigint转账金额(单位:Wei)
databytes调用数据(calldata),纯转账时为 0x
gasLimitbigint最大 Gas 用量
maxFeePerGasbigint每单位 Gas 的最大费用(包含 Base Fee + Priority Fee)
maxPriorityFeePerGasbigint每单位 Gas 的小费(直接给验证者)
accessListarray预声明的访问列表(可选)
Nonce 的重要性

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%)
  • 同一区块内所有交易的 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 GweimaxPriorityFeePerGas = 2 Gwei

effectiveGasPrice = min(50, 20 + 2) = 22 Gwei
退还 = (50 - 22) × gasUsed = 28 Gwei × gasUsed

交易生命周期

一笔交易从构造到最终确认,经历以下阶段:

各阶段详解

  1. 构造交易:DApp 组装 calldata(encodeFunctionData)、设定 Gas 参数
  2. Gas 估算:调用 estimateGas 模拟执行,获取预估 Gas 用量
  3. 签名:钱包使用用户私钥对交易数据进行 ECDSA 签名
  4. 广播:签名后的交易发送到节点,进入 mempool(交易池)
  5. 打包:验证者从 mempool 中选取交易(优先选择 Priority Fee 高的),构建区块
  6. 确认:交易被包含在区块中,获得第一个确认
  7. 最终性:在 PoS 以太坊中,经过 2 个 epoch(约 12.8 分钟)达到最终性(finality),交易不可逆转

前端交易流程实现

构造交易数据

构造合约调用的 calldata
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 编码的参数

发送交易与等待确认

使用 viem 发送交易并等待确认
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)');
}
}

合约写操作

使用 wagmi 调用合约函数
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
// 获取下一个可用的 nonce(包含 pending 状态的交易)
const nonce = await publicClient.getTransactionCount({
address: '0xYourAddress...',
blockTag: 'pending', // 包含 mempool 中的交易
});

连续发送多笔交易

批量发送交易时手动管理 nonce
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 冲突

如果两笔交易使用了相同的 nonce,只有一笔会被打包(通常是 Gas 价格更高的那笔)。另一笔会被丢弃。这也是"交易加速"和"交易取消"的原理。

交易加速与取消

当交易因 Gas 费过低而长时间 pending 时,可以通过发送相同 nonce更高 Gas 费的新交易来替换它。

加速交易

加速 pending 中的交易
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;
}

取消交易

取消 pending 中的交易
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。它有两个核心作用:

  1. 防止重放攻击:同一个签名的交易不能被重复执行
  2. 保证交易顺序:节点按 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 转账时 data0x。部署合约时 data 包含合约字节码 + 构造函数参数。

Q7: 交易失败(revert)Gas 费会退还吗?

答案

不会。交易 revert 后,receipt.status'reverted',但 Gas 费已经被消耗。因为验证者确实执行了计算工作(直到遇到 revert),所以 Gas 不退还。这就是为什么前端需要:

  1. 在发送交易前通过 estimateGas 模拟执行,提前发现 revert
  2. 如果 estimateGas 报错,提示用户交易可能失败

Q8: 如何在前端处理交易的不同状态?

答案

前端需要跟踪交易的完整生命周期:

  1. pending:用户在钱包确认中 → 显示"等待确认"
  2. submitted:已广播到网络 → 显示交易哈希和区块浏览器链接
  3. confirming:等待区块确认 → 显示"交易确认中"
  4. confirmed:交易成功 → 显示成功提示,刷新数据
  5. failed:交易 revert → 显示错误信息
  6. rejected:用户在钱包中拒绝 → 重置状态

推荐使用 wagmi 的 useWaitForTransactionReceipt 来监听确认状态。

Q9: Legacy 交易和 EIP-1559 交易的主要区别是什么?

答案

维度Legacy(Type 0)EIP-1559(Type 2)
Gas 价格设置单一的 gasPricemaxFeePerGas + 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 不应该也不能接触用户的私钥。整个流程:

  1. DApp 构造未签名的交易数据
  2. 通过钱包 API(eth_sendTransaction)请求签名
  3. 钱包弹窗让用户确认,内部用私钥签名
  4. 签名后的交易广播到网络

在服务端脚本中可以用 privateKeyToAccount 直接签名,但在浏览器前端绝不应如此操作。

Q12: 交易回执中的 logs 有什么用?

答案

logs 是交易执行过程中合约通过 emit 触发的事件记录。每个 log 包含:

  • address:触发事件的合约地址
  • topics:事件签名哈希 + indexed 参数(最多 4 个 topic)
  • data:非 indexed 参数的 ABI 编码

前端可以用 decodeEventLog 解析这些日志,获取交易的详细执行信息。例如 ERC-20 转账后解析 Transfer 事件,确认转账的发送方、接收方和金额。

相关链接