你点了"确认"。钱包转圈,然后显示成功。
中间那几秒里,你的交易经过了七个阶段——而其中有一个阶段,它是完全公开地躺在那里等着被处理的,任何人都能看到你要做什么、并且抢在你前面动手。
打个比方
这像把信投进一个透明的邮筒。
信写好了、封好了(签名,别人改不了内容),但在邮差来取之前,它就摊在那儿,谁路过都能读。而且邮差收信时不是按先后顺序,是按谁给的小费多。
这一讲把这七个阶段逐个拆开,每一个都有它自己的失败模式。
一、一笔交易的七个阶段
① 构造与签名 钱包本地完成(第 7 讲)
▼
② 广播 P2P 网络扩散
▼
③ mempool ⚠️ 公开可见的待处理池
▼
④ 打包 提议者/构建者选择并排序
▼
⑤ 执行 状态转移,生成收据
▼
⑥ 上链 区块被广播
▼
⑦ 最终确定 约 12.8 分钟后(第 20 讲)
⭐ 每个阶段都有它自己的失败模式和攻击面。
二、mempool
⭐ 这一节就是那个透明邮筒。 信已经封好(签名不可改),但在被取走之前,内容对所有人可见,而取信的顺序由小费决定。第 34 讲的 MEV 全部长在这一节上。
它是公开的
交易在被打包【之前】,就已经被全网看到了。
⟹ ⭐ 任何人都能看到你要做什么、以及你愿意付多少
⟹ 这是全部 MEV 的基础(第 34 讲)
⚠️ 这条常被忽略的事实有直接后果:
❌ "我先授权,再在下一笔交易里使用" —— 中间存在被抢跑的窗口
❌ "我提交一个包含答案的交易" —— ⭐ 答案在 mempool 里已经公开了
第 8 讲的承诺-揭示方案正是为此而生。
替换与驱逐
替换:⭐ 同一 nonce 的新交易要替换旧的,
通常要求 Gas 价格高出至少 10%
⟹ 这就是钱包"加速"和"取消"的实现(第 11 讲)
驱逐:mempool 有容量上限,价格最低的先被丢弃
被丢弃的交易【不会通知你】,它只是消失了
nonce 间隙:nonce = 5 缺失时,6 和 7 只能排队等待(第 11 讲的队头阻塞)
三、执行与收据
执行产生一份收据,进入区块头的 receiptsRoot(第 9 讲):
收据 = {
状态(成功/失败)
累计 Gas 消耗
日志(事件)
日志的布隆过滤器
}
事件是写给链下的
⚠️ 一个关键但常被误解的事实:
⭐ 合约【无法读取】事件——包括它自己发出的。
事件只被写进收据树,供链下索引器和前端查询。
EVM 里没有任何一条指令可以读日志。
⭐ 这是一个刻意的设计:日志不需要参与状态转移,所以它们可以被"归档"而不影响验证。这让日志比存储便宜一个数量级:
写一个 32 字节存储槽:20000 Gas
发一个带 32 字节数据的事件:约 750 Gas
所以:只给链下看的数据,用事件;需要被合约读的数据,才用存储。 这是最常见也最有效的 Gas 优化之一。
四、EOA 的四个硬限制
外部账户(第 11 讲)在设计上被写死了四件事:
① ⚠️ 只能用 secp256k1 的 ECDSA 签名
⟹ 用不了手机安全芯片的 P-256、用不了抗量子签名、
用不了多签或阈值签名
② 必须用 ETH 付 Gas
⟹ 新用户手上只有 USDC 时,寸步难行
③ 一笔交易只能有一个 to
⟹ "授权 + 交换"必须发两笔,
而这两笔之间存在被抢跑的窗口
④ 私钥丢失 = 资产永久丢失
⟹ 没有社交恢复、没有每日限额、没有紧急冻结
⭐ 账户抽象的目标就是:让账户的验证逻辑变成可编程的代码,而不是写死的 ECDSA 检查。
五、EIP-4337:不改共识层的账户抽象
⭐ 它最聪明的地方在于:完全在协议之外实现,不需要任何硬分叉。
用户 Bundler EntryPoint 合约 钱包合约
│ │ │ │
│ UserOperation │ │ │
├───────────────────▶│ │ │
│ (⭐ 不是交易, │ 打包成一笔【普通交易】 │ │
│ 是一个结构体) ├──────────────────────▶│ │
│ │ │ ① validateUserOp │
│ │ ├──────────────────▶│
│ │ │ 钱包自己决定 │
│ │ │ 怎么验证签名 │
│ │ │◀──────────────────┤
│ │ │ ② 执行调用 │
│ │ ├──────────────────▶│
各个角色:
| 角色 | 作用 |
|---|---|
| UserOperation | 一个"伪交易"结构体,走独立的 mempool |
| Bundler | 把多个 UserOp 打包成一笔真实交易,赚取手续费 |
| EntryPoint | 唯一的、全局共享的入口合约,负责验证与执行的编排 |
| 钱包合约 | 用户自己的账户,验证逻辑完全自定义 |
| Paymaster | 可选,替用户付 Gas(可以收 USDC,也可以完全免费) |
⭐ 于是四个限制全部被解开:
① 签名算法:钱包合约自己实现 validateUserOp ⟹ 任意算法
② Gas 代付:Paymaster ⟹ ⭐ 用户可以用 USDC 付费,甚至完全不付
③ 批量操作:一个 UserOp 可以包含多个调用 ⟹ 授权 + 交换一步完成
④ 恢复机制:社交恢复、每日限额、多签,全部只是钱包合约里的代码
代价:
① 额外的 Gas 开销(EntryPoint 的编排逻辑要钱)
② ⚠️ 需要一个独立的 UserOp mempool 生态
③ EntryPoint 是一个全局单点——它的 bug 会影响所有 4337 钱包
六、EIP-7702:让现有 EOA 长出合约能力
4337 要求用户迁移到一个新的合约钱包地址。而绝大多数人的资产和身份都绑在现有 EOA 上。
EIP-7702 的思路完全不同:
⭐ 在交易里附加一个【授权】,
把某个合约的代码【临时贴到】自己的 EOA 上。
⟹ 这笔交易执行期间,你的 EOA【就是】那个合约
⟹ 于是它获得批量调用、Gas 代付、自定义验证等全部能力
⟹ 而地址不变,资产不用迁移
⭐ 两者的定位对比:
4337:⭐ 完整的账户抽象,但需要新地址、新生态
7702:让【存量】EOA 立刻获得大部分好处,改动小得多
⟹ 二者互补,不是替代关系
七、并行执行
为什么 EVM 难以并行
两笔交易能并行的条件:它们的读写集不相交。
⚠️ 但 EVM 在【执行之前】无法知道一笔交易会碰哪些存储槽——
合约可以根据链上数据动态决定调用谁、写哪个槽。
第 10 讲讲过,UTXO 模型天然没有这个问题(读写集显式声明在交易里)。账户模型换来了共享状态的表达力,代价就是这里。
三条路线
① 强制预声明(Solana)
交易头里必须列出会读写的所有账户。
⭐ 调度器据此并行 ⟹ 简单、确定
代价:开发者必须提前知道所有可能触碰的账户;
动态调用下只能保守地多声明 ⟹ 并行度下降
② 乐观并行(Block-STM,Aptos 等)
这是目前最有意思的一条路:
① 假设所有交易互不冲突,⭐ 全部并行执行
执行时记录每笔交易的【读集】和【写集】
② 写入不直接落盘,而是写进一个【多版本内存】:
每个存储槽记录"哪笔交易的【第几次执行】写了什么值"
③ 验证阶段:严格按【原始顺序】逐笔检查
"我当初读到的那个版本,现在还是正确的版本吗?"
④ 不一致 ⟹ 这笔交易读到了过时数据 ⟹ 撤掉它的写入,重新执行
⚠️ 第 ② 步里"第几次执行"那半句,是最容易被漏掉、也最致命的一点:
若版本号只记"哪笔交易写的",而不记"它的第几次执行":
tx1 第一次执行写了 counter = 1
tx2 读到了它,记下"我读的是 tx1 写的版本"
tx1 因为读到过时数据被重跑,改写成 counter = 2
⟹ ⚠️ tx2 验证时一看:"我读的还是 tx1 写的" —— 通过!
⟹ ⭐ 但它手上那个 1 已经作废了
⟹ 结果与串行不一致,而且【不会报错】,只会算出错的状态根。
⭐ 另外,重跑前必须撤掉这笔交易上一轮的写入——因为重跑可能产生【不同的写集】,旧键留在内存里会永久污染后续读取。
⭐ 关键性质:最终结果与串行执行【完全一致】。
⚠️ 这一点是共识的硬要求,不是优化目标:如果并行执行的结果和串行不同,不同实现的节点会算出不同的状态根,共识立刻崩溃。
⭐ 所以并行在这里是【纯粹的实现优化】,
它必须对协议语义完全透明。
性能特点:
交易之间冲突少 ⟹ 接近线性加速
⚠️ 冲突多(比如所有人都在打同一个热门 NFT 合约)
⟹ 大量重跑,可能比串行还慢
③ 访问列表(以太坊的渐进路径)
EIP-2930 让交易可选地声明访问列表(第 22 讲)。目前它只用于 Gas 折扣,但它为未来的并行调度提供了信息基础——这是一条不需要硬分叉、可以逐步过渡的路。
八、Go:Block-STM 骨架
package parallel
import "sync"
type Key [52]byte
// entry 是某笔交易对某个键的一次写入。
// ⚠️ incarnation 记录"这是该交易的第几次执行"。
// ⭐ 少了它就会出错:交易重跑后值变了、但 txIndex 没变,
// 读方会误以为"我读的版本没变过"而通过验证,拿着过时值继续。
type entry struct {
value []byte
incarnation int
}
// MVMemory 是多版本内存:每个键保存"哪笔交易的第几次执行写了什么"。
type MVMemory struct {
mu sync.RWMutex
data map[Key]map[int]entry // key → txIndex → entry
}
func NewMVMemory() *MVMemory {
return &MVMemory{data: make(map[Key]map[int]entry)}
}
// Read 返回"在 txIndex 之前、离它最近的那次写入"。
// ⭐ 这正是串行执行时该读到的值。
func (m *MVMemory) Read(k Key, txIndex int) (value []byte, writer, incarnation int) {
m.mu.RLock()
defer m.mu.RUnlock()
best, bestE := -1, entry{}
for idx, e := range m.data[k] {
if idx < txIndex && idx > best { // 只看排在自己前面的写入
best, bestE = idx, e
}
}
if best < 0 {
return nil, -1, 0 // −1 表示读的是区块开始时的初始状态
}
return bestE.value, best, bestE.incarnation
}
func (m *MVMemory) Write(k Key, txIndex, incarnation int, v []byte) {
m.mu.Lock()
defer m.mu.Unlock()
if m.data[k] == nil {
m.data[k] = make(map[int]entry)
}
m.data[k][txIndex] = entry{value: v, incarnation: incarnation}
}
// ClearWrites 撤掉某笔交易上一轮留下的全部写入。
// ⚠️ 重跑可能产生【不同的写集】——旧键若不清掉,会永远污染后续读取。
func (m *MVMemory) ClearWrites(txIndex int) {
m.mu.Lock()
defer m.mu.Unlock()
for _, versions := range m.data {
delete(versions, txIndex)
}
}
// ReadRecord 记录一次读操作:读了哪个键,以及读到的是哪个版本。
type ReadRecord struct {
Key Key
Writer int // −1 表示区块初始状态
Incarnation int
}
type TxResult struct {
Index int
ReadSet []ReadRecord
WriteSet map[Key][]byte
}
// Validate 检查一笔交易的读集是否仍然有效。
// ⭐ 核心问题:我当初读到的版本,现在还是正确的版本吗?
// ⚠️ 必须同时比对 writer 和 incarnation——只比 writer 会漏掉"同一笔交易重跑后值变了"。
func (m *MVMemory) Validate(r *TxResult) bool {
for _, rec := range r.ReadSet {
_, w, inc := m.Read(rec.Key, r.Index)
if w != rec.Writer || inc != rec.Incarnation {
return false // 期间有更靠前的交易写了这个键,或重跑改了值
}
}
return true
}
// Execute 是 Block-STM 的主循环。
// ⭐ 先乐观并行执行,再按【原始顺序】逐笔验证,失败就重跑。
// ⚠️ 最终结果必须与串行执行完全一致——这是共识的硬要求,不是优化目标。
func Execute(txs []func(int, *MVMemory) *TxResult, workers int) []*TxResult {
mv := NewMVMemory()
results := make([]*TxResult, len(txs))
inc := make([]int, len(txs))
// ── 第一轮:乐观并行执行 ──
var wg sync.WaitGroup
sem := make(chan struct{}, workers)
for i := range txs {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
r := txs[idx](idx, mv)
for k, v := range r.WriteSet {
mv.Write(k, idx, 0, v)
}
results[idx] = r
}(i)
}
wg.Wait()
// ── 第二轮:严格按原始顺序验证 ──
// ⭐ 走到第 i 笔时,前 i−1 笔都已定稿,
// 所以拿它的读集去对照当前状态,就等于对照串行执行的结果。
for i := 0; i < len(results); i++ {
if mv.Validate(results[i]) {
continue
}
mv.ClearWrites(i) // ⚠️ 先撤旧写入,再重跑
inc[i]++
r := txs[i](i, mv)
for k, v := range r.WriteSet {
mv.Write(k, i, inc[i], v)
}
results[i] = r
}
return results
}
九、本讲小结
- 交易七阶段:签名 → 广播 → mempool → 打包 → 执行 → 上链 → 最终确定。每个阶段都有自己的攻击面。
- ⚠️⭐ mempool 是公开的——交易在被打包前全网可见。这是全部 MEV 的基础,也意味着**“提交一个含答案的交易"根本不保密**。
- 替换需要 Gas 价格高出约 10%;被驱逐的交易不会通知你,只是消失。
- 合约无法读取事件,包括自己发出的。 事件只写进收据树,供链下使用。这让它比存储便宜一个数量级(约 750 vs 20000 Gas)——只给链下看的数据用事件,需要被合约读的才用存储。
- EOA 的四个硬限制:只能用 secp256k1、必须用 ETH 付 Gas、一笔只能一个
to、私钥丢了就没了。 - EIP-4337 完全在协议之外实现,通过 UserOperation → Bundler → EntryPoint → 钱包合约的流水线,把验证逻辑变成可编程代码。代价是额外 Gas、独立生态,且 EntryPoint 是全局单点。
- EIP-7702 让存量 EOA 临时"贴上"合约代码,地址不变、资产不迁移。它和 4337 是互补而非替代。
- EVM 难以并行的根源:执行前无法知道会碰哪些存储槽——这正是账户模型换取共享状态表达力所付的代价。
- 三条路线:Solana 强制预声明(简单但难写)、Block-STM 乐观并行(多版本内存 + 按序验证 + 冲突重跑)、以太坊的访问列表渐进路径。
- ⭐⭐ 并行执行的结果必须与串行完全一致——这是共识的硬要求,不是优化目标。否则不同实现会算出不同状态根,共识立刻崩溃。
- 乐观并行在冲突多时可能比串行还慢(所有人打同一个热门合约)。
思考题
- 说出交易七个阶段中每一个阶段各自的一种失败模式。
- mempool 公开带来哪些具体风险?“先 approve 再 swap"的窗口期能被怎么利用?
- 为什么合约不能读事件?如果 EVM 加一条"读日志"指令,会带来什么问题?
- 一个协议需要记录"每次交易的价格”。什么情况下该用事件、什么情况下必须用存储?
- EOA 的四个限制里,哪一个对新用户上手影响最大?为什么?
- 画出 EIP-4337 的完整调用链,标出每一步谁在付 Gas。Paymaster 怎么保证自己不被薅?
- 比较 4337 和 7702:一个已经用了三年的 EOA 想获得批量交易能力,两种方案分别要做什么?
- 为什么 UTXO 模型天然可并行而账户模型不能?请从"读写集何时可知"来回答。
- Block-STM 中,为什么验证必须按原始顺序?如果乱序验证会发生什么?
- 构造一个 Block-STM 会大量重跑的场景,估算它相比串行的性能。这说明并行化的收益取决于什么?