BSC 基于 go-ethereum 的 Tracing 接口演进全解:从 EVMLogger 到 Hooks 与 State Journaling
【免费下载链接】bscA BNB Smart Chain client based on the go-ethereum fork项目地址: https://gitcode.com/GitHub_Trending/bs/bsc
导读
本文以 BNB Smart Chain 客户端仓库(基于 go-ethereum 的 fork)中的 core/tracing/CHANGELOG.md 为主线,系统梳理了以太坊系客户端 EVM 追踪(tracing)接口从 v1.14.0 到最新 Unreleased 版本的完整演进历程。你将掌握tracing.Hooks结构体的全部事件监听器及其签名、四大状态变化原因类型(Balance/Gas/Nonce/Code ChangeReason)的取值语义、状态日志(State Journaling)回滚机制的原理与用法,以及 BSC 在 Parlia 共识下对系统交易(system transaction)追踪的扩展实现,从而具备编写自定义 live tracer 与解读链上状态变化事件流的完整能力。
一、为什么要有一份 Tracing 接口的 Changelog
Tracing 是 EVM 可观测性的核心:从 RPC 层的debug.traceTransaction,到节点内部的 live tracing,再到供给量统计、MEV 分析,都依赖 EVM 在状态转换过程中向外抛出的事件。这类接口一旦变更,所有基于其上的 tracer 都要跟着调整,因此官方以 CHANGELOG 形式逐版本记录每一处增删改,保证自定义 tracer 开发者可以对照迁移。
core/tracing/CHANGELOG.md 记录的是core/tracing包(即 "live tracing" 的低层钩子库)的变更,覆盖 v1.14.0 至今的所有版本。它与 hooks.go、journal.go 等源码一一对应,是理解该接口最权威的文档入口。
二、v1.14.0:破坏性重构,EVMLogger 谢幕、Hooks 登场
2.1 从接口到结构体:为什么必须改
v1.14.0 是 tracing 接口历史上最大的一次破坏性变更,其背景是新的 live tracing 特性(对应 go-ethereum PR #29189)。核心变化一句话概括:
EVMLogger接口被彻底移除,取而代之的是新的tracing.Hooks结构体。
Hooks内部保存的是指向事件监听函数的指针(function pointers)。EVM 在内部通过这些函数指针派发事件,如果 tracer 没有实现某个钩子,EVM 可以直接跳过该事件而不必调用一个空实现。这正是本次重构的初衷——旧接口要求 tracer 实现所有方法(哪怕空实现),而新方案:
- 事件派发可跳过(nil 检查),性能与代码整洁度双赢;
- 未来新增钩子无需破坏既有 tracer;
- 事件接收者可以动态指派。
2.2 4byte tracer 的前后对比(原文档示例)
CHANGELOG 以 4byte tracer 的构造函数为例展示了迁移方式。旧写法返回一个满足接口的实例:
func newFourByteTracer(ctx *tracers.Context, _ json.RawMessage) (tracers.Tracer, error) { t := &fourByteTracer{ ids: make(map[string]int), } return t, nil }新写法返回指向tracers.Tracer结构体的指针,并显式指定事件监听器:
func newFourByteTracer(ctx *tracers.Context, _ json.RawMessage) (*tracers.Tracer, error) { t := &fourByteTracer{ ids: make(map[string]int), } return &tracers.Tracer{ Hooks: &tracing.Hooks{ OnTxStart: t.onTxStart, OnEnter: t.onEnter, }, GetResult: t.getResult, Stop: t.stop, }, nil }在当前仓库的 eth/tracers/native/4byte.go 中可以看到该 tracer 的最终形态:它注册在tracers.DefaultDirectory(RPC 调用的 tracer 目录),构造函数为newFourByteTracer(ctx *tracers.Context, cfg json.RawMessage, chainConfig *params.ChainConfig) (*tracers.Tracer, error),只挂载了OnTxStart与OnEnter两个钩子,并额外实现了GetResult(返回收集到的 4byte 标识符集合)与Stop(中断信号)。注意tracers.Tracer现在也是结构体而非接口。
2.3 事件监听器:命名与签名的全面调整
改名总览(Capture* → On*)
CHANGELOG 明确指出,所有方法名从Capture*模式改为On*模式,且签名也有多处修改:
| 旧方法 | 新方法 | 关键变化 |
|---|---|---|
CaptureStart | OnEnter(顶层帧由 depth=0 区分) | 已删除,见下文 |
CaptureEnd | OnExit(顶层帧由 depth=0 区分) | 已删除,见下文 |
CaptureTxStart | OnTxStart(vm *VMContext, tx *types.Transaction, from common.Address) | 传入完整交易对象与from;*VMContext替代旧的*vm.EVM |
CaptureTxEnd | OnTxEnd(receipt *types.Receipt, err error) | 返回完整 receipt |
CaptureEnter | OnEnter(depth int, typ byte, from, to common.Address, input []byte, gas uint64, value *big.Int) | 新增depth表示调用栈深度(顶层为 0);OnEnter的触发时机提前到调用开始,因此部分原先不触发 Enter/Exit 的错误场景现在也会触发,个别交易会多出一层调用记录 |
CaptureExit | OnExit(depth int, output []byte, gasUsed uint64, err error, reverted bool) | 新增depth与reverted(指示该调用帧是否被回滚) |
CaptureState | OnOpcode(pc uint64, op byte, gas, cost uint64, scope tracing.OpContext, rData []byte, depth int, err error) | op为 byte 类型,可转型为vm.OpCode;不再传*vm.ScopeContext,改为tracing.OpContext |
CaptureFault | OnFault(pc uint64, op byte, gas, cost uint64, scope tracing.OpContext, depth int, err error) | 同上 |
删除的方法
CaptureStart与CaptureEnd被删除——这两个钩子原本标记交易的顶层调用帧。现在顶层帧信息由OnEnter/OnExit的depth参数区分(depth == 0 即顶层);CaptureStart中的create bool参数可以从OnEnter的typ byte推断,即vm.OpCode(typ) == vm.CREATE。
新增的状态观察方法
live tracing 的一半价值在于增强对区块链状态的观测能力。CHANGELOG 列举了可通过 API 调用的自定义 tracer 受益的新方法(完整方法列表见 hooks.go 中的 Hooks 结构体):
OnGasChange(old, new uint64, reason GasChangeReason):跟踪一笔交易及其子调用内 gas 的完整生命周期——先是购买 gas,然后逐笔消耗与退款,最后退回剩余部分;OnBalanceChange(addr common.Address, prev, new *big.Int, reason BalanceChangeReason):跟踪账户余额变化,尽可能给出原因(转账、购买 gas、提款入账等);OnNonceChange(addr common.Address, prev, new uint64):跟踪账户 nonce 变化;OnCodeChange(addr common.Address, prevCodeHash common.Hash, prevCode []byte, codeHash common.Hash, code []byte):跟踪账户代码变化;OnStorageChange(addr common.Address, slot common.Hash, prev, new common.Hash):跟踪账户存储变化;OnLogChange(log *types.Log):跟踪 EVM 发出的日志。
需要说明的是,在当前仓库的 hooks.go 中日志钩子实际命名为OnLog(LogHook = func(log *types.Log)),CHANGELOG 中写作OnLogChange属于早期命名,以源码为准。
2.4 支撑类型:VMContext 与 OpContext
钩子签名中反复出现的两个接口,在 hooks.go 中定义:
OpContext提供执行某条 opcode 时的上下文:MemoryData()(内存)、StackData()(栈)、Caller()/Address()、CallValue()/CallInput(),以及ContractCode()(当前合约字节码,v1.14.10 新增,对应 PR #30466);VMContext提供 EVM 执行环境:Coinbase、BlockNumber、Time、Random、BaseFee,以及StateDB(让 tracer 可以直接读取全量状态,见下文)。
三、四大 ChangeReason 类型:状态变化的"原因标注"
为了让 tracer 理解状态变化背后的业务语义,tracing包定义了四个枚举类型。它们在 hooks.go 中均有完整定义,并为调试与日志输出生成了 String() 方法(对应仓库中的 gen_balance_change_reason_stringer.go 等四个生成文件,v1.15.4 中GasChangeReason与NonceChangeReason补齐了自动生成的 String(),对应 PR #31234)。
3.1 BalanceChangeReason
用于标注余额变化原因,取值包括:
- 发行类:
BalanceIncreaseRewardMineUncle(叔块奖励)、BalanceIncreaseRewardMineBlock(出块奖励)、BalanceIncreaseWithdrawal(信标链提款)、BalanceIncreaseGenesisBalance(创世分配); - 交易费用类:
BalanceIncreaseRewardTransactionFee(小费归矿工/构建者)、BalanceDecreaseGasBuy(购买 gas,部分按 EIP-1559 销毁)、BalanceIncreaseGasReturn(未用 gas 退还); - DAO 分叉类:
BalanceIncreaseDaoContract、BalanceDecreaseDaoAccount; - 常规转账类:
BalanceChangeTransfer(调用转账,发送方为减、接收方为增)、BalanceChangeTouchAccount(零值触碰创建账户); - 自毁类:
BalanceIncreaseSelfdestruct、BalanceDecreaseSelfdestruct、BalanceDecreaseSelfdestructBurn; - 回滚类:
BalanceChangeRevert(调用失败后余额回滚,仅当 tracer 使用 journaling 包装层时才发出,v1.15.0 引入); - BSC 特有(当前仓库扩展):
BalanceDecreaseBSCDistributeReward = 210(系统地址向验证者分发奖励时的扣减)、BalanceIncreaseBSCDistributeReward = 211(验证者收到奖励)。
3.2 GasChangeReason
CHANGELOG 特别提示:这类原因分两种——以GasChangeTx开头的每笔交易只发一次,以GasChangeCall开头的按调用帧可能多次发出。
- 交易级:
GasChangeTxInitialBalance、GasChangeTxIntrinsicGas、GasChangeTxRefunds、GasChangeTxLeftOverReturned,以及 v1.15.0 新增的GasChangeTxDataFloor(交易数据为达到最低 gas 要求而额外支付的 gas,恒为负向变化); - 调用级:
GasChangeCallInitialBalance、GasChangeCallLeftOverReturned、GasChangeCallLeftOverRefunded、GasChangeCallContractCreation/ContractCreation2、GasChangeCallCodeStorage、GasChangeCallOpCode、GasChangeCallPrecompiledContract、GasChangeCallStorageColdAccess(EIP-2929 冷访问)、GasChangeCallFailedExecution(未 revert 的执行失败烧掉剩余 gas); - Verkle 见证相关(仅 post-Verkle 生效,分叉前不应出现):
GasChangeWitnessContractInit(v1.14.4,合约创建初始化阶段加入 witness)、GasChangeWitnessContractCreation(v1.14.4,合约创建收尾阶段)、GasChangeWitnessCodeChunk(v1.14.4,合约代码块加入 witness)、GasChangeWitnessContractCollisionCheck(v1.14.9,合约地址碰撞检查时加入 witness); - 特殊值:
GasChangeIgnored = 0xFF,表示该 gas 变化由事件直发手动跟踪,应被忽略。
3.3 NonceChangeReason
v1.15.0 引入,取值:NonceChangeGenesis(创世 nonce)、NonceChangeEoACall(EOA 调用)、NonceChangeContractCreator(创建合约的账户 nonce 增加)、NonceChangeNewContract(新合约自身 nonce)、NonceChangeAuthorization(EIP-7702 授权导致的 nonce 变化)、NonceChangeRevert(调用失败回滚 nonce,由 journaling 层发出)。
3.4 CodeChangeReason(Unreleased 新增)
当前 Unreleased 版本引入,取值:CodeChangeContractCreation(CREATE/CREATE2 部署新合约)、CodeChangeGenesis(创世或初始设置写入代码)、CodeChangeAuthorization(EIP-7702 Set Code Authorization 设置代码)、CodeChangeAuthorizationClear(EIP-7702 委托被清空为零地址)、CodeChangeSelfDestruct(自毁清除代码)、CodeChangeRevert(调用失败回滚代码,由 journaling 层发出)、CodeChangeSystemContractUpgrade(BSC 特有扩展:硬分叉期间系统合约代码升级)。
四、状态日志(State Journaling):调用回滚的自动补偿
4.1 问题背景
在 v1.15.0 之前,tracer 收到状态变化事件后,必须自己跟踪被修改的账户与存储槽,并在某个调用帧失败时手工回滚这些变化。这既繁琐又容易出错。v1.15.0 引入了一个状态日志库:当调用帧失败(revert)时,自动向 tracer 发出"反向变化"(reverse change)事件。
4.2 使用方式(原文档示例)
使用前需要先把钩子包装起来再注册 tracer:
func init() { tracers.LiveDirectory.Register("test", func (cfg json.RawMessage) (*tracing.Hooks, error) { hooks, err := newTestTracer(cfg) if err != nil { return nil, err } return tracing.WrapWithJournal(hooks) }) }4.3 覆盖的状态变化
journaling 库覆盖的状态变化包括:
OnBalanceChange——注意回滚时携带BalanceChangeRevert原因;OnNonceChange、OnNonceChangeV2;OnCodeChange;OnStorageChange。
4.4 底层实现原理
core/tracing/journal.go 给出了完整实现,核心机制是快照(snapshot)与修订(revision)栈:
WrapWithJournal(hooks)做合法性检查:hooks不能为 nil;不能同时设置OnNonceChange与OnNonceChangeV2;不能同时设置OnCodeChange与OnCodeChangeV2。随后复制原 Hooks,用 journal 自己的实现覆盖OnTxEnd、OnEnter、OnExit及各类状态变化钩子;OnEnter每次进入调用帧时调用snapshot()记录修订点;- 状态变化事件(余额、nonce、代码、存储)被追加到
entries日志,同时转发给原 tracer; OnExit时若reverted == true则调用revert():从日志尾部向前回放entry.revert(hooks),把状态"反向"回调(如余额从 new 回到 prev,并附带BalanceChangeRevert原因);若未回滚则仅popRevision()丢弃修订;OnTxEnd重置日志(每个交易有自己的 EVM 调用栈)。
其中revert事件的方向是反向广播:如余额balanceChange.revert会以(addr, new, prev, BalanceChangeRevert)调用原钩子,存储则以(addr, slot, new, prev)反向调用,代码则以新旧 codeHash/code 对调后回放。
4.5 测试用例印证
core/tracing/journal_test.go 中的测试用例完整验证了这套语义:
TestJournalIntegration:模拟两层调用帧,内层帧全部变更后 revert,外层成功,验证余额回到 100、nonce 回到 0、代码保留、存储只保留外层变更;TestJournalTopRevert:顶层也 revert 时,余额回到 0、nonce 回到 0;TestJournalNestedCalls:复杂嵌套调用下,只有第 4 个子帧 revert,验证其变更被撤销而其余变更保留;TestNonceIncOnCreate与TestNonceIncOnCreateParentReverts:验证 CREATE 帧自身失败时创建者 nonce 增加不被该帧 revert(因为 nonce 自增发生在 EVM 快照之前),但父帧 revert 时会一并回滚——journal.go 通过"将当前帧的修订点推进到该条目之后"实现这一精确语义;TestOnNonceChangeV2/TestOnCodeChangeV2:验证 V2 钩子回滚后 nonce 与代码正确复原;TestAllHooksCalled:用反射遍历Hooks全部字段,确保包装层不会吞掉任何钩子。
五、系统调用与 BSC 的系统交易钩子
5.1 系统调用钩子(v1.14.3 起)
为了显式标记系统合约的执行,v1.14.3 新增:
OnSystemCallStart():EVM 开始处理系统调用时触发。系统调用发生在交易范围之外,之后会跟随正常的 EVM 执行事件;OnSystemCallEnd():系统调用结束时触发。
当时唯一的系统调用是按 EIP-4788 更新父信标区块根(beacon block root),更多系统调用计划在未来硬分叉中加入。v1.15.0 中OnSystemCallStart被弃用,升级为OnSystemCallStartV2(vm *VMContext),使 tracer 在系统调用期间也能访问 EVM 上下文(见 hooks.go 中OnSystemCallStartHook与OnSystemCallStartHookV2的定义)。
5.2 BSC 特有:系统交易钩子(当前仓库扩展)
在 hooks.go 中可以看到 go-ethereum 上游没有的扩展:OnSystemTxStart与OnSystemTxEnd(OnSystemTxStartHook/OnSystemTxEndHook),用于标记Parlia 共识引擎内执行的系统交易——例如系统合约升级、奖励分配等"链上"交易。其事件流为:
OnSystemTxStart OnTxStart OnTxEnd OnSystemTxEnd即系统交易钩子与普通OnTxStart/OnTxEnd叠加触发:仅实现 OnTxStart/End 的 tracer 也能正确追踪这类交易;而实现了 OnSystemTxStart/End 的 tracer 则可以对系统交易做特殊路由。另有OnSystemTxFixIntrinsicGas(uint64)钩子,用于系统交易(执行时不计算内在 gas)在结束时从总 gas 中扣除内在 gas 以修正统计。
在 consensus/parlia/parlia.go 的applyTransaction中可以看到实际调用链:构造 EVM 后,先tracer.OnSystemTxStart(),再tracer.OnTxStart(evm.GetVMContext(), expectedTx, msg.From);随后以 defer 方式注册OnTxEnd与OnSystemTxEnd(defer 后进先出,保证OnTxEnd先于OnSystemTxEnd执行),最终通过applyMessage完成状态转换。
5.3 BlockHash 与状态读取扩展
OnBlockHashRead(blockNum uint64, hash common.Hash)(v1.15.0 新增):EVM 读取某个区块的 blockhash 时触发;VMContext.StateDB.GetCodeHash(addr) common.Hash(v1.15.0):获取账户的代码哈希;VMContext.StateDB.GetTransientState(addr, slot) common.Hash(v1.14.12,对应 PR #30531):访问合约的 transient storage;BlockEvent.TD(Total Difficulty)字段在 v1.15.0 被移除(对应 PR #30744),区块事件不再携带总难度。
六、小版本中的细节行为变更
- v1.14.12:
OnCodeChange钩子现在会在合约因selfdestruct移除代码时被调用,此前这类场景不发出任何 code change 事件——如果你在统计代码变更,需要注意自毁事件现在也会出现在事件流中; - v1.15.0:
BalanceChangeReason新增BalanceChangeRevert;GasChangeReason新增GasChangeTxDataFloor;NonceChangeReason新增并包含NonceChangeRevert; - Unreleased:
OnCodeChange(addr, prevCodeHash, prevCode, codeHash, code)被弃用,替代为带原因参数的OnCodeChangeV2(addr, prevCodeHash, prevCode, codeHash, code, reason CodeChangeReason)(对应 go-ethereum PR #32525),原因覆盖合约创建、创世初始化、EIP-7702 授权、自毁与回滚。
七、实战:如何编写并注册一个 Live Tracer
结合上述接口,一个完整的 live tracer 由三部分组成:注册(tracers.LiveDirectory.Register)、钩子实现(tracing.Hooks)、以及可选的 journaling 包装。
仓库中 eth/tracers/live/supply.go 是现成的"供给量统计" tracer 范例:
func init() { tracers.LiveDirectory.Register("supply", newSupplyTracer) } func newSupplyTracer(cfg json.RawMessage) (*tracing.Hooks, error) { var config supplyTracerConfig if err := json.Unmarshal(cfg, &config); err != nil { return nil, fmt.Errorf("failed to parse config: %v", err) } // ... return &tracing.Hooks{ OnBlockchainInit: t.onBlockchainInit, OnBlockStart: t.onBlockStart, OnBlockEnd: t.onBlockEnd, OnGenesisBlock: t.onGenesisBlock, OnTxStart: t.onTxStart, OnBalanceChange: t.onBalanceChange, OnEnter: t.onEnter, OnExit: t.onExit, OnClose: t.onClose, }, nil }该 tracer 的统计逻辑完全建立在本章所述的钩子语义之上:
OnBalanceChange根据BalanceChangeReason分类累加:BalanceIncreaseRewardMineBlock/MineUncle计入发行奖励、BalanceIncreaseWithdrawal计入提款、BalanceDecreaseSelfdestructBurn计入杂项销毁,其余原因忽略(创世余额在OnGenesisBlock中处理,因为BalanceIncreaseGenesisBalance在每个交易中并不出现);OnEnter/OnExit用调用栈跟踪内部调用,在OnExit(depth == 0)时统计子调用中的销毁量(如 SELFDESTRUCT 且 from == to 的 burn),并在reverted时丢弃;OnBlockStart/OnBlockEnd按块统计 EIP-1559 与 blob 的销毁(gasUsed × baseFee,blob 则通过eip4844.CalcBlobFee计算);- 输出采用 lumberjack 轮转日志,以 JSONL 逐块写入
supply.jsonl。
另一份 eth/tracers/live/noop.go 注册了一个空 tracer(tracers.LiveDirectory.Register("noop", newNoopTracer)),是最小的 live tracer 骨架,适合作为新 tracer 的起点。
需要区分两个注册目录:RPC 可调用的自定义 tracer 注册在tracers.DefaultDirectory(如 4byte),而 live tracer 注册在tracers.LiveDirectory。如果你要追踪的状态变更需要回滚补偿,记得在注册前用tracing.WrapWithJournal(hooks)包装,如 CHANGELOG 中的示例所示。
八、结语:接口演进的规律与迁移要点
纵观 core/tracing/CHANGELOG.md 与 core/tracing/hooks.go,可以总结出该接口的三条演进主线:
- 从"实现全部方法"到"按需挂载":
EVMLogger接口 →Hooks结构体,事件派发从"必须实现"变为"nil 即跳过",这是后续一切新增钩子能够零破坏推进的前提; - 从"知道变了"到"知道为什么变":Balance/Gas/Nonce/Code 四大 reason 枚举不断完善,配合 v1.15.0 的 state journaling,让 tracer 不仅能观测状态变化,还能准确理解变化的原因与回滚语义;
- 从"链上交易"到"系统范围":OnSystemCallStart/End(EIP-4788)与 BSC 的 OnSystemTxStart/End(Parlia 系统交易)将追踪范围扩展到交易之外的共识层与系统合约执行。
对于计划编写自定义 tracer 的开发者,迁移要点可归纳为:以On*命名挂载你关心的钩子;用 reason 枚举区分状态变化语义;调用帧级回滚交给WrapWithJournal;涉及 BSC 系统交易时补上OnSystemTxStart/End;注意OnCodeChange已弃用,新代码一律使用带 reason 的OnCodeChangeV2。
【免费下载链接】bscA BNB Smart Chain client based on the go-ethereum fork项目地址: https://gitcode.com/GitHub_Trending/bs/bsc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考