交易全解:类型、签名与 EIP-1559 费用(师生对话实录)
Web3 区块链系列 · 阶段 2 · 以太坊核心 · 第 11/57 篇
上一篇:《账户模型与状态机:以太坊的世界状态》 · 下一篇:《EVM 执行模型与 Gas:逐条执行一笔交易》
学习大纲:《Web3 区块链学习总纲》
写在前面
上一篇立好了状态机视角:交易是状态转移函数的输入。但我在浏览器里看一笔交易,满屏字段(type、gasLimit、maxFeePerGas、accessList……)基本不认识;手续费那几个数字更是玄学——我付的 gas 到底给了谁?
继续对话老办法:AI 当老师我当学生,有问题就打断。这堂课全程本地起一条真链(anvil),亲手发交易、故意把 nonce 用错、把回执拆到字段级——学完browser 里任何一笔交易都应该是白话文。
课程路线图:
① 交易的骨架字段 → ② 亲手发一笔 1559 → ③ 五种类型的演进 → ④ 双价机制:你的钱给了谁 → ⑤ nonce 与内存池的一生 → ⑥ 签名与哈希
环境:WSL2 Ubuntu-22.04 + Foundry 1.7.1(anvil 本地链 + cast);主网对照数据回看第 10 篇。官方文档:ethereum.org — Transactions、EIP-1559。
第 1 课:交易的骨架——一个信封上要写什么
🧑🏫 老师:
先看最朴素的转账交易要声明什么。一个信封的比喻刚好够用:
一笔交易(信封):
┌────────────────────────────────────────────┐
│ to: 收件人(地址;空 = 部署合约,3.8 篇)│
│ value: 装了多少 ETH(wei) │
│ data: 信纸(calldata:转账留空、调用写满) │
│ nonce: 发件人的第几封信(防重放,上篇插问 1)│
│ gasLimit: 这封信最多肯付的邮费上限 │
│ chainId: 寄往哪条链(1=主网,防跨链重放) │
│ type: 信封的版式(0/1/2/3/4,下一课) │
└────────────────────────────────────────────┘
信封封口 = from 的私钥签名(0.3 篇)先立三个最容易混的:
to+value+data是「内容」,其余是「元数据」。纯转账data为空;to指向合约且data有货时,就是「程序调用」——上一篇说的「交易从指令升级为程序调用」,载体就是这个data;gasLimit是你批的上限,不是你付的数。实际付费 = 实际消耗 × 实际单价,上限只是防失控(2.3 篇的 out-of-gas 就撞它);chainId是 EIP-155(2016)加的:签名覆盖链 ID,同一笔签名拿到别的链上就无效——「跨链重放」从此被焊死。
一句话收口:交易 = 声明(to/value/data)+ 护栏(nonce/gasLimit/chainId)+ 一枚签名;data 是否为空是「转账」与「调用」的分水岭。
第 2 课:亲手发一笔 EIP-1559 交易
🧑🎓 学生: 别讲了,让我发一笔。
🧑🏫 老师:
正合我意。本地起链(anvil 预置 10 个测试账户,私钥公开在启动横幅里——只在本地链这么干),用 0 号账户给 1 号账户转 0.5 ETH:
anvil --port 18545 # 终端 A:起本地链
cast send 0x7099…79C8 --value 0.5ether \
--private-key 0xac09…ff80 --rpc-url http://127.0.0.1:18545 # 终端 B本机实跑的回执(节选字段,其余同理):
blockHash 0xad31f3857840e1bb7d74457b07d1c4ae4c4619c289ac3ef12e5b3c150d8f664a
blockNumber 1
from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
to 0x70997970C51812dc3A010C7d01B50e0d17dc79C8
type 2 ← EIP-1559 交易
gasUsed 21000 ← 纯转账的固定消耗
effectiveGasPrice 1000000001 ← 实际单价(wei)= baseFee + 小费
status 1 (success) ← 状态转移成功
transactionHash 0x8c3252f67bd73ef79154c8e7847c4e867908ef0ce5ac7865ce5ffa42a55ba93a逐个消化:type 2 宣告这是 1559 版式(第 3 课展开);gasUsed 21000 是纯转账的固定底价(EVM 给「改两个余额数字」定的 intrinsic cost,2.3 篇拆它的构成);status 1 就是上一篇的原子性落地——整笔成功才写状态,失败(revert)则回滚但 gas 照扣。
再发一笔、同时查当前 baseFee,把「实际单价」的账拆开:
第二笔回执:effectiveGasPrice 875175001
当前 baseFee: 875175000
差额:875175001 - 875175000 = 1 wei —— 我给的小费实际单价 = baseFee + priorityFee,这就是第 4 课的门票。
一句话收口:cast send 三秒发出一笔 type 2 交易;回执里 gasUsed × effectiveGasPrice = 你付的钱,effectiveGasPrice = baseFee + 小费。
插问 1:同一笔「转账」和「调用Uniswap」的交易,在数据上差在哪?
🧑🎓 学生: 浏览器里有的交易 data 是一串 0x,有的是一大坨十六进制——本质区别就一个字段?
🧑🏫 老师:
就一个字段,但值得拆开看那「一坨」的结构。调合约时 data 的格式是 ABI 编码:
data = 函数选择器(4 字节) + 参数(ABI 编码,32 字节对齐)
例:transfer(address to, uint256 amount)
选择器 = keccak256("transfer(address,uint256)")[0:4] = 0xa9059cbb
data = 0xa9059cbb
0000…000<32 字节的地址> ← 参数 1
0000…000<32 字节的金额> ← 参数 2三个推论顺出来:
- 合约怎么知道你想调哪个函数?比对 data 前 4 字节和它编译期所有函数的选择器——这就是「函数选择器」的全部工作原理(3.5 篇接口与 ABI 的地基);
- 钱包能提前告诉你这笔交易会干什么——解析 data 反查函数签名(浏览器上那个
transfer(address,uint256)解码就是这么来的);钓鱼交易伪装再好,data 解码不会说谎; - 参数是定长的——32 字节对齐让 EVM 解析无歧义但也浪费空间(一个
true也占 32 字节),calldata 按字节计费(2.3 篇),所以「参数怎么排」直接是钱。
顺手一条常用命令:cast calldata "transfer(address,uint256)" 0xAbC… 1000——本地就能生成那坨 data,不需要真的发交易。
一句话收口:data = 4 字节选择器 + ABI 参数;转账 data 为空、调用 data 是「程序指令」——浏览器解码、钱包预览、钓鱼识破全靠它。
第 3 课:五种类型——一部小演进史
🧑🎓 学生: type 有 0/1/2/3/4?五个版本各为什么出现?
🧑🏫 老师:
type 字段是「信封版式」,每次改版都是被真实痛点逼的:
| type | EIP | 年份 | 新增的东西 | 解决的痛点 |
|---|---|---|---|---|
| 0 | —(Legacy) | 2015 | — | 原始格式:gasPrice 一口价 |
| 1 | EIP-2930 | 2020 | accessList:预先声明要碰的地址 | 预热存储便宜点(为 1559 铺路,少见) |
| 2 | EIP-1559 | 2021 | baseFee 双价市场 | 费用估算玄学、价格波动(第 4 课) |
| 3 | EIP-4844 | 2024 | blob 数据(单独计价、短期存储) | L2 的数据可用性太贵(8.2 篇主角) |
| 4 | EIP-7702 | 2025 | 授权列表(EOA 委托合约代码) | EOA 能力贫乏(上篇第 5 课) |
读这张表的姿势:每一型都向后兼容——老钱包继续发 type 0 也完全合法(按一口价规则处理),新能力是「可选用」。这本身就是 1.4 篇「共识层改动必须极度难用」的生存术:升级用「新增选项」而不是「修改语义」,老节点不升级也活得好。
日常开发你只需要跟 type 2 打交道(cast/MetaMask 默认都是它);type 3 属于 L2 世界、type 4 是账户抽象的新大陆,到对应阶段再深入。
一句话收口:五个 type = 五次被痛点逼出来的改版;新增不改旧是共识层升级的生存术;日常主线只有 type 2。
第 4 课:双价机制——你的手续费到底给了谁
🧑🎓 学生: 主问题了:baseFee 和 priorityFee,我付的钱各去了哪?
🧑🏫 老师:
1559 之前是「第一价格拍卖」:每人盲猜一个 gasPrice,矿工挑价高的打包——插问 2 再骂它。1559 把价格拆成两个独立旋钮:
你声明的两个上限:
maxFeePerGas ← 单价总帽(baseFee + 小费 不得超过它)
maxPriorityFeePerGas ← 给打包者的「小费」帽
链上每块自动算出的:
baseFee ← 协议定价,按块内拥堵程度上调/下调
(块越满下块越贵,目标半满)
实际结算(本机实测数字):
effectiveGasPrice = min(maxFeePerGas, baseFee + 你的小费)
= 875175000(baseFee,销毁) + 1(小费,给验证者)
你付的钱 = gasUsed × effectiveGasPrice
= 21000 × 875175001 wei两个去向,注意力放在这:
- baseFee 部分:当场销毁。不是给验证者,是直接从流通中消失(
eth_burn)——以太坊在 1559 后经常处于「发行 < 销毁」的通缩状态。你付的邮费大部分是「给全网的负通胀税」; - 小费部分:给打包这条交易的验证者。它是你唯一能出价竞争的旋钮——拥堵时想插队,就加小费。
baseFee 的调节规则值得记一句:每块按(实际用量 − 目标半满)差值的 1/8 上下调——最多升 12.5%/块、空块按比例降。它是协议公式不是市场喊价,所以钱包才能给出可靠的估算(对比插问 2 的拍卖时代)。
一句话收口:1559 双价 = 协议定价的 baseFee(销毁、可预测)+ 你出价的小费(给验证者、插队用);大部分手续费不是给谁的工资,是烧掉。
插问 2:为什么要发明 1559?老的拍卖有什么不好?
🧑🎓 学生: 「价高者得」听起来很市场、很合理啊——哪里不好了?
🧑🏫 老师:
第一价格拍卖的毛病不在「不公平」,在没有价格发现机制:
- 出价靠猜:你看不到别人的报价,只能猜「要出多少才能进下一个块」。猜低了排队,猜高了白送钱——每一笔都是一次盲拍,钱包的「建议 gas 价」本质是占卜;
- 价格锯齿:需求稍涨,报价跳升;块一空,价格跳水——手续费的波动率远超实际拥堵变化,商户没法定价、用户体验稀烂(2021 年「gas 是玄学」的梗就是这时代的);
- 多付的不退:拍卖机制下你出多少扣多少,没有「找零」概念。
1559 的三个解法正好一一对应:协议公式定价(baseFee 可计算,钱包给出靠谱估算——不再是占卜)、目标半满的缓冲(一半容量当减震器,价格随拥堵平缓移动)、找零机制(只按 effectiveGasPrice 结算,你多声明的上限自动退回)。
第三个副作用反而成了叙事转折:baseFee 销毁让 ETH 从「纯增发资产」变成「可能通缩」的资产——这直接改写了 ETH 的价值论叙事。协议设计里「顺手的经济重构」,1559 是教科书案例。
一句话收口:拍卖时代的病 = 盲猜、锯齿、不找零;1559 的药 = 公式定价、半满缓冲、按实结算——顺手还把 ETH 变成了可通缩资产。
第 5 课:nonce 与内存池——交易的一生
🧑🎓 学生: 信发出去之后、上块之前,它在哪?nonce 用错了会怎样?
🧑🏫 老师:
中间地带叫内存池(mempool)——每个节点本地攒「已广播、未上链」交易的候车区。交易的一生:
签名 → 广播 → 【内存池:pending】→ 被打包 → 【已上链】→ 后续块加深 → 【finalized】
(等待 nonce 对齐 / 出价竞争) (可被 1.2 式浅分叉回滚) (PoS 终局,8.1)nonce 在候车区当检票员。三个实验把它的脾气全摸清(本机 anvil 实跑):
实验一:重发旧 nonce——当场被拒。账户 nonce 已是 2,硬发 nonce=0:
Error: error code -32003: nonce too low实验二:跳号——无限期卡死。当前 nonce=2,故意发 nonce=10:
(8 秒无回执——交易停在内存池)
A0 的 nonce 仍是: 2 ← 已上链计数一动不动那笔 nonce=10 躺在池里等 3~9 全部到齐才可能上链——「卡住」的真相是「排队等号」。用户视角的经典事故:手滑把 nonce 发成了未来值,之后所有交易全部堵死在它后面。
实验三:补上缺口——连锁放行。把缺的 nonce=2 发出去:
=== 发 nonce=2(补上缺口)===
status 1 (success)
transactionHash 0xc9f551bb3fb54351e8b7cb9de48227f0fe9c0e5dbf1251f76c7033876dae9320
=== 现在 A0 nonce: 4 === ← 从 2 直接跳到 4!
=== 最新块高: 3 === ← 新块把 nonce=2 和池里等着的 nonce=3 一起打包nonce 从 2 直接变 4——补上缺口后,池里排队的 nonce=3 被连锁放行,同一个块一起上链。(nonce=10 那笔还在等 4~9。)这就是「内存池按账户排成严格队列」的活演示。
一句话收口:内存池 = 按 nonce 排队的候车区;旧号被拒、跳号卡死、补号连锁放行——nonce 是每个账户的严格 FIFO 序列。
插问 3:交易卡住了(或发错了),怎么撤回?
🧑🎓 学生: 我把 nonce 跳号卡死了,或者一笔转账发错了想撤——有 undo 吗?
🧑🏫 老师:
没有 undo,但有「替换」——这恰好是 nonce 队列的副产品。规则:同一账户、同一 nonce 的交易,内存池里只允许存在一个;新来的若出价更高(通常要求 +10%),替换旧的。
所以撤卡/撤错的操作都是同一个套路:
想撤的那笔:nonce=10,maxPriorityFee = 2 gwei(躺在池里)
对策: 发一笔 nonce=10、maxPriorityFee = 3 gwei 的【空交易】
(to = 自己,value = 0,data 空——什么都不做的一笔)
结果: 高价空交易替换原交易 → 上链 → 原「内容」作废两个边界要懂:只能替换还没上链的——已经打包的交易无法追回(原子性 + 历史);替换本身也要花钱——空交易按 21000 gas 照付。钱包里的「加速 / 取消」按钮,背后就是这招(业内叫 RBF,replace-by-fee)。
顺带把 MEV 的钩子埋在这:既然「同一位置可被高价替换」,那盯着内存池、抢在你前面插队就是一门生意——三明治攻击的物理基础正是这块(6.5 篇展开)。你现在已经掌握它的全部前置知识了。
一句话收口:撤交易 = 同 nonce 高价空交易替换(RBF);只能撤未上链的,替换本身也要付钱;「可插队」这把双刃剑就是 MEV 的门。
第 6 课:签名与哈希——信封怎么封口
🧑🎓 学生: 最后补上拼图:签名到底签了什么?transactionHash 是怎么算出来的?
🧑🏫 老师:
签名的对象是整笔交易的规范化序列化,不是什么摘要口播。流程:
① 交易对象(含 nonce/gas 上限/to/value/data/chainId/…)
│ RLP 编码:一种极简的长度前缀序列化
│ ([nonce, gasLimit, …] → 定长字节串,看懂结构即可,不要求手写)
▼
② keccak256(RLP(交易)) = 交易哈希(未签名版)
│
▼
③ ECDSA 私钥签名(0.3 篇那套,产出 r、s、v)
│
▼
④ 签名后的完整交易 = RLP(字段 + r,s,v)
transactionHash = keccak256(④)两个实务要点:
- chainId 在签名范围内(EIP-155 的精髓)——把交易原样搬到别的链,签名验证直接失败,因为那条链期望的 chainId 不同。这就是「防跨链重放」的实现细节;
- transactionHash 是「签名后全文」的哈希——所以哪怕内容完全相同的两笔转账(同 from/to/value/nonce 也不同了,其实内容必不同),哈希也必不同;而 1.3 插问 3 提过 SegWit 之前比特币「改签名能改 txid」的延展性 bug,以太坊的 RLP+签名设计没有这个问题。
RLP 一句话就够:「把结构压成字节串的定长前缀法」——真正要会的是用工具看:cast tx $HASH --rpc-url … 能打印任意交易的原始字段。你现在有完整的解读能力了:收据看结果(第 2 课)、tx 看内容(本课)、data 解码看意图(插问 1)。
一句话收口:签名 = ECDSA(RLP(整笔交易)),chainId 在签名内;transactionHash = 签名后全文的 keccak——浏览器里那串 0x… 的来历。
小结
- 骨架:to/value/data 是内容,nonce/gasLimit/chainId 是护栏;data 空=转账、有货=调用。
- 实发(anvil):type 2 交易、gasUsed 21000 底价、status 1 = 原子性成功。
- calldata:4 字节选择器 + ABI 参数——浏览器解码与反钓鱼的依据。
- 五型演进:2930 预热 → 1559 双价 → 4844 blob → 7702 委托;新增不改旧。
- 1559 双价:effectiveGasPrice = baseFee(销毁)+ 小费(给验证者);baseFee 按块半满目标公式调节。
- 拍卖时代的病与 1559 的药:盲猜→公式定价、锯齿→缓冲、不找零→按实结算(顺手造就 ETH 通缩叙事)。
- nonce 三连(实跑):too low 拒收、跳号卡池、补号连锁放行(nonce 2→4)。
- RBF:同 nonce 高价空交易替换——撤回与加速的原理,也是 MEV 插队的门。
- 签名:ECDSA over RLP(全文)、chainId 防跨链重放;transactionHash = 签名后全文哈希。
验收清单(做完再进下一篇):
思考题:为什么 1559 之后「gas 价格预言机」变简单了,但「小费」反而成了新的博弈场?(提示:baseFee 是公式、人人都同价——插队的竞争只剩小费一个旋钮;想想这对验证者激励和 MEV 意味着什么。)
下一篇:《EVM 执行模型与 Gas:逐条执行一笔交易》——钻进信封里的那张「信纸」:EVM 这台栈式计算机怎么一条条跑完你的 calldata,以及每条指令的价目表。
本篇实验命令(可照抄)
# 终端 A:本地链(anvil 横幅里有 10 个预置账户与私钥,仅本地可用)
anvil --port 18545
RPC=http://127.0.0.1:18545
PK0=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
A1=0x70997970C51812dc3A010C7d01B50e0d17dc79C8
# 1559 交易与回执
cast send $A1 --value 0.5ether --private-key $PK0 --rpc-url $RPC
cast base-fee --rpc-url $RPC # 对照 effectiveGasPrice = baseFee + 小费
# nonce 三连(当前 nonce=2 时)
cast send $A1 --value 0.1ether --nonce 0 --private-key $PK0 --rpc-url $RPC # too low
cast send $A1 --value 0.1ether --nonce 10 --private-key $PK0 --rpc-url $RPC # 卡池(Ctrl-C)
cast send $A1 --value 0.1ether --nonce 2 --private-key $PK0 --rpc-url $RPC # 补缺口→连锁放行
cast nonce 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url $RPC # 看 nonce 跳变
# calldata 生成(不用发交易)
cast calldata "transfer(address,uint256)" 0xAbC0000000000000000000000000000000000001 1000