EIP-1559 余额检查缺陷与 evm t8n 回归测试:go-ethereum testdata/12 深度解析
【免费下载链接】go-ethereumGo implementation of the Ethereum protocol项目地址: https://gitcode.com/gh_mirrors/go/go-ethereum
本篇技术指南以 go-ethereum 仓库中的 testdata/12 测试说明 为核心,完整还原 2021 年 Ropsten 测试网上发生的一起 EIP-1559 共识问题:节点在按max_fee_per_gas × gas_limit做余额预检时遗漏了转账金额(value),导致本应被拒绝的交易被错误地打包进区块。读者读完本文后,将掌握该缺陷的复现命令、evm t8n状态转换测试工具的输入输出格式,以及余额检查在 core/state_transition.go 中的底层实现逻辑,并能在本地亲手运行这套回归测试。
一、缺陷背景:Ropsten 上的 EIP-1559 共识漏洞
伦敦升级(London fork,EIP-1559)引入了一套全新的费用市场机制:每笔交易需要声明maxFeePerGas(费用上限)与maxPriorityFeePerGas(矿工小费上限),同时区块头中携带由协议动态调整的baseFee。交易实际支付的 gas 单价为:
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)问题出在余额预检环节。以太坊在真正执行交易前,必须先确认发送方账户余额足以支付“最坏情况”的扣款,即gasLimit × gasPrice + value。在 Ropsten 上,go-ethereum 的某个实现缺陷导致执行max_fee_per_gas * gas_limit检查时没有把 value(转账金额)计入总需求。于是,一笔余额恰好只够支付 gas 却不够支付“gas + 转账金额”的交易,被错误地放行进入区块,造成共识不一致——这正是 testdata/12 这个回归测试所要钉死的场景。
二、测试夹具拆解:三个 JSON 文件
该测试位于 cmd/evm/testdata/12/,由三个输入文件构成,它们分别描述交易的初始世界状态、待执行的交易集合与区块环境。
2.1 alloc.json:初始账户状态
alloc.json 只定义了一个账户,即交易发送方:
{ "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b" : { "balance" : "84000000", "code" : "0x", "nonce" : "0x00", "storage" : { "0x00" : "0x00" } } }关键参数:
balance:84000000 wei(十六进制为0x501bd00),这是整场“博弈”的焦点——恰好等于gasLimit × maxFeePerGas,却少于加上 value 后的总需求;code:0x,纯 EOA 账户,无合约代码;nonce:0x00,账户尚未发出过交易。
2.2 txs.json:一笔 EIP-1559 动态费用交易
txs.json 包含一笔type: 0x2(EIP-1559 DynamicFeeTx)交易:
[ { "input" : "0x", "gas" : "0x5208", "nonce" : "0x0", "to" : "0x1111111111111111111111111111111111111111", "value" : "0x20", "v" : "0x0", "r" : "0x0", "s" : "0x0", "secretKey" : "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8", "chainId" : "0x1", "type" : "0x2", "maxFeePerGas" : "0xfa0", "maxPriorityFeePerGas" : "0x20", "accessList" : [] } ]各字段的十进制换算与含义:
| 字段 | 十六进制 | 十进制 | 说明 |
|---|---|---|---|
gas | 0x5208 | 21000 | 恰好等于一笔简单转账的内在 gas(intrinsic gas) |
value | 0x20 | 32 wei | 转账金额,正是被旧实现遗漏的部分 |
maxFeePerGas | 0xfa0 | 4000 wei | 愿意支付的 gas 单价上限 |
maxPriorityFeePerGas | 0x20 | 32 wei | 愿意支付给矿工的小费上限 |
chainId | 0x1 | 1 | 主网链 ID |
secretKey | — | — | 测试用私钥,t8n工具用它本地推导出发送方地址0xa94f5374...并对交易签名 |
注意v/r/s均为零:t8n模式下交易签名由工具内部根据secretKey自动补齐,无需外部预签名。
2.3 env.json:区块执行环境
env.json 描述交易将要被打包进的那个区块的上下文:
{ "currentCoinbase" : "0x2adc25665018aa1fe0e6bc666dac8fc2697ff9ba", "currentDifficulty" : "0x020000", "currentNumber" : "0x01", "currentTimestamp" : "0x03e8", "previousHash" : "0xfda4419b3660e99f37e536dae1ab081c180136bb38c837a93e93d9aab58553b2", "currentGasLimit" : "0x0f4240", "currentBaseFee" : "0x20" }currentBaseFee:0x20(32 wei),配合currentNumber: 0x01与previousHash,让t8n工具确认区块处于伦敦升级之后的 EIP-1559 环境;currentGasLimit:0x0f4240(1000000),足以容纳这笔 21000 gas 的交易。
2.4 三份文件的协作关系
t8n(Transition 的缩写)工具的工作模式是:以alloc.json为世界初始状态,以env.json为区块上下文,逐一执行txs.json中的交易,最终输出执行后的新世界状态与执行结果。这三个文件共同构成一个最小化的“单个区块”模拟环境,非常适合构造和回归验证此类边界条件。
三、复现步骤:用 evm t8n 跑出拒绝结果
首先在仓库根目录构建evm工具(其入口与全部用法见 cmd/evm/main.go 与 cmd/evm/README.md):
go build ./cmd/evm然后执行原文档给出的复现命令(在cmd/evm目录下):
dir=./testdata/12 && ./evm t8n --state.fork=London --input.alloc=$dir/alloc.json --input.txs=$dir/txs.json --input.env=$dir/env.json --output.alloc=stdout --output.result=stdout命令参数含义:
| 参数 | 作用 |
|---|---|
t8n | 进入状态转换(transition)子命令模式 |
--state.fork=London | 指定执行所依据的硬分叉规则,此处启用 EIP-1559 |
--input.alloc | 世界初始状态文件 |
--input.txs | 待执行交易列表文件 |
--input.env | 区块环境文件 |
--output.alloc=stdout | 将执行后的账户状态输出到标准输出 |
--output.result=stdout | 将执行结果输出到标准输出 |
四、修复后的正确行为:交易被拒绝
在原文档记录的历史输出中(该输出来自缺陷修复后的版本),evm t8n给出了完整执行日志与 JSON 结果。执行日志部分:
INFO [03-09|10:43:12.649] rejected tx index=0 hash=ccc996..d83435 from=0xa94f5374Fce5edBC8E2a8697C15331677e6EbF0B error="insufficient funds for gas * price + value: address 0xa94f5374Fce5edBC8E2a8697C15331677e6EbF0B have 84000000 want 84000032" INFO [03-09|10:43:12.650] Trie dumping started root=e05f81..6597a5 INFO [03-09|10:43:12.650] Trie dumping complete accounts=1 elapsed="46.393µs"日志中的error字段直接给出了拒绝原因:insufficient funds for gas * price + value: have 84000000 want 84000032。这里的两个数字可以精确还原校验逻辑:
需要金额 = gasLimit × maxFeePerGas + value = 21000 × 4000 + 32 = 84000032 账户余额 = 84000000 84000000 < 84000032 → 余额不足,交易拒绝缺口恰好是value的 32 wei——这正是旧实现遗漏的部分。
标准输出的 JSON 结果完整如下:
{ "alloc": { "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { "balance": "0x501bd00" } }, "result": { "stateRoot": "0xe05f81f8244a76503ceec6f88abfcd03047a612a1001217f37d30984536597a5", "txRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", "receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", "logsHash": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "receipts": [], "rejected": [ { "index": 0, "error": "insufficient funds for gas * price + value: address 0xa94f5374Fce5edBC8E2a8697C15331677e6EbF0B have 84000000 want 84000032" } ], "currentDifficulty": "0x20000", "gasUsed": "0x0", "currentBaseFee": "0x20" } }对输出结果的关键解读:
result.rejected数组记录了被拒绝交易的下标(index: 0,即 txs.json 中的第一笔)与拒绝原因;- 因为交易被拒绝,
receipts为空、gasUsed为0x0,区块没有消耗任何 gas; alloc中发送方余额保持0x501bd00(84000000)不变,状态未被修改;stateRoot/txRoot/receiptsRoot与空区块的预期根一致(txRoot与receiptsRoot相同,均为空列表的 RLP 根),从世界状态的角度确认“什么都没发生”。
五、源码级原理:buyGas 中的最坏情况余额检查
要理解缺陷的本质,需要进入余额预检的实现——core/state_transition.go 中的buyGas()函数。其注释对该约束的描述是:
The balance requirement is the worst-case ETH the tx may need to lock up:
msg.GasLimit × max(msg.GasPrice, msg.GasFeeCap) + msg.Value, plusblobGas × msg.BlobGasFeeCapunder Cancun.
即:余额预检必须覆盖交易可能锁定的最坏情况金额 = gasLimit × max(实际价格, 费用上限) + value(Cancun 之后还需加上 blob 相关费用)。核心代码逻辑(约在 core/state_transition.go#L435-L490):
func (st *stateTransition) buyGas() error { mgval := new(uint256.Int).SetUint64(st.msg.GasLimit) _, overflow := mgval.MulOverflow(mgval, st.msg.GasPrice) // ... 溢出检查 ... balanceCheck := new(uint256.Int).Set(mgval) if st.msg.GasFeeCap != nil { // 用 GasFeeCap(而非实际 GasPrice)重新计算 worst-case balanceCheck.SetUint64(st.msg.GasLimit) if _, overflow := balanceCheck.MulOverflow(balanceCheck, st.msg.GasFeeCap); overflow { // ... 溢出检查 ... } } if st.msg.Value != nil { // 将转账金额加入余额需求 —— 旧实现遗漏的就是这一步 if _, overflow := balanceCheck.AddOverflow(balanceCheck, st.msg.Value); overflow { // ... 溢出检查 ... } } // ... Cancun 下的 blobGas 检查 ... if have, want := st.state.GetBalance(st.msg.From), balanceCheck; have.Cmp(want) < 0 { return fmt.Errorf("%w: address %v have %v want %v", ErrInsufficientFunds, st.msg.From.Hex(), have, want) } // 扣除 gas 费用 st.state.SubBalance(st.msg.From, mgval, tracing.BalanceDecreaseGasBuy) return nil }这段实现揭示了几个关键设计点:
为什么用 GasFeeCap 而不是有效价格:实际扣款按
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)计算,但余额检查必须采用最坏情况(max(GasPrice, GasFeeCap)),否则交易在后续执行中可能因价格波动而“扣款超出余额”。在本文的测试数据中,maxFeePerGas = 4000远大于baseFee + maxPriorityFeePerGas = 64,因此balanceCheck使用的是 4000 而非 64,这正是“worst-case”语义的体现。value 必须显式加入:即使
balanceCheck已按gasLimit × gasFeeCap计算,转账金额仍会进一步消耗余额,必须单独叠加。修复前该步缺失,导致缺口恰好为value(此处 32 wei)。溢出防护:
MulOverflow/AddOverflow系列方法确保即使恶意构造超大maxFeePerGas或value也不会产生 uint256 回绕,而是以required balance exceeds 256 bits的错误拒绝。
最终余额不足时,函数返回包装了 core/error.go#L69-L71 中ErrInsufficientFunds的错误——该错误常量定义即为"insufficient funds for gas * price + value",与测试日志、JSON 输出中的错误文案完全对应:
// ErrInsufficientFunds is returned if the total cost of executing a transaction // is higher than the balance of the user's account. ErrInsufficientFunds = errors.New("insufficient funds for gas * price + value")六、在仓库中运行回归测试
testdata/12 不仅支持手工复现,也纳入了自动化的回归测试套件。cmd/evm/t8n_test.go 驱动evm t8n逐目录执行cmd/evm/testdata/下的所有用例(包括本文的 12 号用例),并将输出与预期结果比对。在仓库根目录运行:
go test ./cmd/evm/ -run TestT8n -v该测试通过internal/reexec机制以子进程方式重新执行evm主程序(见 t8n_test.go 中的TestMain),并注入--output.basedir等参数管理输出目录,从而保证每次运行的结果可复现。只要buyGas()中价值叠加逻辑被回归破坏,该用例就会立即失败,防止 Ropsten 共识事故以任何形式复发。
七、从缺陷到共识安全的启示
回顾 testdata/12 的完整链路,可以得到三点工程启示:
- 共识校验必须考虑最坏情况:凡是涉及“能否进入区块”的检查,都不能只看名义价格,而要按交易可能锁定的最大金额(
gasLimit × feeCap + value,以及未来 blob 交易中的blobGas × blobGasFeeCap)计算,否则就会出现“逻辑上余额够、实际扣款不够”的共识分叉; - 回归测试是共识缺陷的保险丝:testdata/12 用三个精炼的 JSON 文件 + 一条命令,把一笔“差 32 wei”的边界交易固化成了永久回归用例。
gasCap × gasLimit恰好等于余额、加上 value 后超出的构造方式,正是缺陷的最小化复现; - t8n 工具的价值:无需启动完整节点即可精确模拟单区块状态转换,输出中同时呈现状态根、交易根、回执根与被拒交易明细,是开发与审计 EIP 行为的高效工具。
以该测试为范本,任何新的费用机制或余额校验调整(例如 Cancun 的 blob 费用检查,同样在buyGas()中实现)都应当配套此类边界用例,确保“不该打包的交易永不打包”。
【免费下载链接】go-ethereumGo implementation of the Ethereum protocol项目地址: https://gitcode.com/gh_mirrors/go/go-ethereum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考