Ethereum JSON-RPC 和 Go-Ethereum:从“会调用”到“看懂源码”
适合谁读:刚开始学区块链开发、会一点 Go 的同学(第一、二、三部分),以及想弄清 Geth 内部怎么运转的进阶同学(第四部分)。
阅读路线:
- 是什么:JSON-RPC 是怎样一门“和节点对话的语言”
- 谁来说:Geth 如何实现这门语言,
ethclient又是怎么替我们“翻译”的- 怎么用:以
ethereum.org 官方 JSON-RPC 文档 为基准,合并
ethereum-json-rpc.com 和 execution-apis 规范,把 83 个方法逐个讲清楚。每个方法都配一段可以直接运行的 Go 示例,并穿插查区块、转账、部署合约、订阅事件等常见实战场景的核心代码
- 为什么能用:钻进 Geth 源码,看一个请求进门之后,经过 RPC、EVM、状态树、数据库、P2P 网络的整个旅程
版本说明:本文所有 Go 示例和源码解析都基于 go-ethereum v1.17.8(撰写时的最新版本),示例中用到的
ethclient/gethclient接口均已在该版本下编译验证。示例数据统一使用 Sepolia 测试网(区块5671744、一个简单的Store合约等),方便你直接复制运行。
目录
- 第一部分:什么是 Ethereum JSON-RPC
- 第二部分:Go-Ethereum(Geth)与 JSON-RPC 的关系
- 第三部分:JSON-RPC 方法大全(对照 ethclient)
- 第四部分:Go-Ethereum 源码解析
第一部分:什么是 Ethereum JSON-RPC
1.1 一句话定义
Ethereum JSON-RPC 是应用程序和以太坊节点之间通信的标准接口协议。 它基于轻量的 JSON-RPC 2.0 规范,钱包、DApp、后端服务等外部程序通过它向节点发请求,用来读取链上数据或者发送交易。
打个比方:
| 现实世界 | 以太坊世界 |
|---|---|
| 银行的金库与账本 | 区块链(所有区块、账户状态) |
| 银行柜台的工作人员 | 以太坊节点(Geth、Erigon、Nethermind、Reth……) |
| 柜台业务申请单(格式统一) | JSON-RPC 请求(格式统一) |
| 你(来办业务的客户) | 钱包 / DApp / 你写的 Go 后端 |
你不需要知道银行金库在哪、账本怎么装订,只要按格式填好“申请单”递进柜台,就能查余额、办转账。JSON-RPC 就是这张申请单的格式标准。
1.2 拆开名字看:JSON + RPC
- RPC(Remote Procedure Call,远程过程调用):像调本地函数一样调用另一台机器上的函数。你写
getBalance(addr),真正执行的是远端节点。 - JSON:请求和响应都用 JSON 文本编码,任何语言都能读写,人也能直接看懂。
所以 JSON-RPC 就是用 JSON 描述“我要调哪个函数、传什么参数”的远程调用协议。它本身不规定传输方式,以太坊节点一般同时支持三种通道:
| 通道 | 默认地址 | 特点 | 典型场景 |
|---|---|---|---|
| HTTP | http://127.0.0.1:8545 | 一问一答,无状态 | 查询、发交易 |
| WebSocket | ws://127.0.0.1:8546 | 长连接,服务端可以主动推送 | 订阅新区块、事件(eth_subscribe) |
| IPC | ~/.ethereum/geth.ipc | 本机 Unix Socket,最快也最安全 | 同一台机器上的运维脚本、geth attach |
在 Go 里,连接哪种通道只取决于传给 ethclient.Dial 的地址前缀:
httpClient, _ := ethclient.Dial("https://eth-sepolia.g.alchemy.com/v2/<API_KEY>") // HTTP:查询、发交易
wsClient, _ := ethclient.Dial("wss://eth-sepolia.g.alchemy.com/v2/<API_KEY>") // WebSocket:订阅
ipcClient, _ := ethclient.Dial("/root/.ethereum/geth.ipc") // IPC:本机节点
订阅新区块、订阅合约事件时必须用
wss://或 IPC 地址,原因就在上面这张表里:HTTP 没法让服务端主动推消息。
1.3 一次完整的“对话”长什么样
请求(客户端 → 节点):
{
"jsonrpc": "2.0",
"method": "eth_getBalance",
"params": ["0x25836239F7b632635F815689389C537133248edb", "latest"],
"id": 1
}
| 字段 | 含义 |
|---|---|
jsonrpc | 协议版本,固定为 "2.0" |
method | 要调用的方法名,格式为 命名空间_方法名 |
params | 参数数组,顺序有意义 |
id | 请求编号,响应会原样带回,用来把“问”和“答”配上对 |
成功响应(节点 → 客户端):
{ "jsonrpc": "2.0", "id": 1, "result": "0x1651bb4f1d2cf5a2a" }
失败响应:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "invalid argument 0: hex string has length 38, want 40 for common.Address"
}
}
你可以直接用 curl 体验一下(不需要写任何代码):
curl -s -X POST -H "Content-Type: application/json" --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' https://ethereum-sepolia-rpc.publicnode.com
批量请求(Batch)
把多个请求放进一个 JSON 数组里一次发出,可以减少网络往返:
[
{ "jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 1 },
{ "jsonrpc": "2.0", "method": "eth_blockNumber", "params": [], "id": 2 }
]
1.4 小白必须掌握的 4 条“填表规则”
规则 1:方法名 = 命名空间 + 下划线 + 方法
| 命名空间 | 作用 | 举例 |
|---|---|---|
eth_ | 核心:区块、交易、账户、合约调用 | eth_getBalance、eth_call |
net_ | 网络信息 | net_version、net_peerCount |
web3_ | 客户端信息、工具函数 | web3_clientVersion、web3_sha3 |
debug_ | 调试、交易追踪(Geth 实现) | debug_traceTransaction |
txpool_ | 交易池(Geth 实现) | txpool_status |
admin_ | 节点管理(危险,别对外开放) | admin_peers |
engine_ | 执行层与共识层之间的通信(合并后新增) | engine_newPayloadV3 |
trace_ / erigon_ | 其他客户端的扩展(Geth 没有) | trace_block |
规则 2:数字一律是十六进制字符串(QUANTITY)
"0x0"表示 0,"0x41"表示 65,不能有前导零("0x041"是错的)- 金额单位一律是 wei:1 ETH = 10^18 wei
规则 3:字节数据也是十六进制字符串(DATA)
- 地址 20 字节:
"0x2583...8edb"(40 个十六进制字符) - 哈希 32 字节:
"0xae71...a4b5"(64 个十六进制字符) - 偶数长度,可以有前导零(
"0x0041"合法)
规则 4:区块参数可以写“标签”
| 标签 | 含义 |
|---|---|
"latest" | 最新的区块(最常用) |
"pending" | 正在打包、尚未上链的“待定”状态 |
"safe" | 合并后新增:大概率不会被回滚的区块 |
"finalized" | 合并后新增:已经最终确定、不可回滚的区块 |
"earliest" | 创世区块 |
"0x568b40" | 指定区块号(这里是 5671744) |
常见误区:在 Go 里给
ethclient传nil当区块号,就等于"latest";想要"pending",要用PendingXxx系列方法(如PendingNonceAt)。
1.5 读操作 vs 写操作
JSON-RPC 方法可以粗分成两类,这个区分决定了你要不要私钥、要不要花 gas:

- 读:节点在本地数据上算一下就返回,免费、不需签名、不上链。比如查余额、
eth_call调合约的view函数。 - 写:必须先在本地用私钥签名,再通过
eth_sendRawTransaction发给节点广播。节点从来不需要拿到你的私钥。
一句话记住:转账、部署合约、调用会修改状态的合约方法 → eth_sendRawTransaction;只查询 → eth_call。不管你用的是 abigen 生成的合约代码、手写 ABI 编码,还是 MetaMask 这样的浏览器钱包,最终都落到这两个方法上。
第二部分:Go-Ethereum(Geth)与 JSON-RPC 的关系
2.1 Geth 是什么
Go-Ethereum(简称 Geth)是以太坊基金会用 Go 语言编写的官方执行层客户端,也是目前使用最广的以太坊节点软件。运行 Geth,你的电脑就成了以太坊网络中的一个节点:它会和其他节点同步区块、验证交易、在 EVM 里执行智能合约、保存世界状态。
合并(The Merge,2022 年 9 月)之后,一个完整的以太坊节点 = 执行层客户端(Geth 等,负责交易和状态)+ 共识层客户端(Prysm、Lighthouse 等,负责 PoS 出块与投票)。两者之间通过
engine_命名空间的 JSON-RPC 通信(默认端口 8551,需要 JWT 鉴权)。
2.2 一句话说清二者关系
Geth 是 Ethereum JSON-RPC 规范的“实现者”和“服务端提供者”;JSON-RPC 则是 Geth 与外部世界交互的“标准语言和通信接口”。
继续用银行做比喻:
- JSON-RPC 规范 = 银行业统一的“业务申请单格式”(由
ethereum/execution-apis 维护)
- Geth = 一家按这个格式开柜台的银行(Erigon、Nethermind、Reth 是另外几家银行,格式相同,各自还有一些“特色业务”,比如
trace_、erigon_) - Infura / Alchemy / PublicNode = 帮你在各地开好柜台的“连锁代办点”,背后跑的还是这些客户端
- ethclient = Geth 官方提供的“填单小助手”,你说
client.BalanceAt(...),它帮你填好eth_getBalance的申请单

2.3 看一眼真实代码:一个方法是怎么“两头对上”的
客户端(ethclient/ethclient.go):ethclient 的每个方法几乎都是一行 CallContext:
// BlockNumber 返回最新区块号
func (ec *Client) BlockNumber(ctx context.Context) (uint64, error) {
var result hexutil.Uint64
err := ec.c.CallContext(ctx, &result, "eth_blockNumber") // 直接写死了 JSON-RPC 方法名
return uint64(result), err
}
服务端(internal/ethapi/api.go):Geth 用普通的 Go 方法实现业务:
// BlockNumber 返回当前链头的区块号
func (api *BlockChainAPI) BlockNumber() hexutil.Uint64 {
header, _ := api.b.HeaderByNumber(context.Background(), rpc.LatestBlockNumber)
return hexutil.Uint64(header.Number.Uint64())
}
然后把这个对象以命名空间 "eth" 注册到 rpc.Server。注册时 Geth 用反射扫描所有导出方法,把方法名首字母变小写、加上命名空间前缀:
命名空间 "eth" + 方法 BlockNumber → "eth_blockNumber"
命名空间 "eth" + 方法 GetBalance → "eth_getBalance"
命名空间 "debug" + 方法 TraceTransaction → "debug_traceTransaction"
这就是 JSON-RPC 方法名和 Geth 源码之间的“命名规律”。以后你在 Geth 源码里想找 eth_xxx 的实现,直接搜 func (api *...) Xxx( 就行。(第四部分 4.6 会拆开这套反射注册机制。)
2.4 启动 Geth 时和 JSON-RPC 有关的参数
geth --sepolia --http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3 --ws --ws.port 8546 --ws.api eth,net,web3
| 参数 | 作用 |
|---|---|
--http / --ws | 开启 HTTP / WebSocket 服务(IPC 默认开启) |
--http.api / --ws.api | 开放哪些命名空间。debug、admin、personal 不要对公网开放 |
--http.addr | 监听地址。设成 0.0.0.0 等于对所有人开放,务必配合防火墙 |
--http.corsdomain / --http.vhosts | 浏览器跨域、Host 白名单 |
--authrpc.port / --authrpc.jwtsecret | 给共识层用的 engine_ 接口(8551 + JWT) |
2.5 ethclient、gethclient、rpc.Client 三兄弟
| 包 | 定位 | 什么时候用 |
|---|---|---|
ethclient | 标准 eth_ / net_ 方法的类型安全封装 | 90% 的场景 |
ethclient/gethclient | Geth 特有扩展:eth_getProof、带状态覆盖的 eth_call、eth_createAccessList、订阅 pending 交易等 | 需要 Geth 扩展功能时 |
rpc.Client | 最底层:手写方法名 + 参数,什么方法都能调 | ethclient 没封装的方法(debug_、trace_、叔块查询等) |
拿到底层 rpc.Client 的方式只有一行:
client, _ := ethclient.Dial(url)
rpcClient := client.Client() // *rpc.Client,和 ethclient 共用同一条连接
geth := gethclient.New(rpcClient) // 需要 Geth 扩展时再包一层
小结:第一部分讲的是“申请单的格式”,第二部分讲的是“谁来受理、谁帮你填单”。接下来第三部分,我们把每一种“业务”都办一遍。
第三部分:JSON-RPC 方法大全(对照 ethclient)
3.0 准备工作
第一步:建项目、装依赖
mkdir go-eth-demo && cd go-eth-demo && go mod init github.com/local/go-eth-demo
go get github.com/ethereum/go-ethereum@v1.17.8
第二步:准备一个节点地址和测试币
不需要自己跑节点,注册一个 RPC 服务商即可拿到 Sepolia 测试网地址:
| 服务商 | 说明 |
|---|---|
注册后获得 https://... 和 wss://... 两种地址 | |
| 免注册,直接可用,偶尔不稳定 |
发交易需要测试 ETH,可以去各服务商的水龙头(faucet)领取,例如 Alchemy Sepolia Faucet、
Google Cloud Sepolia Faucet。
也可以在本机起一条开发链,自带 10~20 个有余额的测试账户:
npx hardhat node
(或者使用 Foundry 的 anvil,默认地址都是 http://127.0.0.1:8545)。这些测试私钥是公开的,千万不要在主网使用。
第三步:示例用到的 Store 合约
不少示例会和下面这个极简合约交互:它有一个版本号、一个 bytes32 → bytes32 的映射,每次写入都会发出 ItemSet 事件。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract Store {
event ItemSet(bytes32 indexed key, bytes32 value);
string public version; // 存储槽 0
mapping(bytes32 => bytes32) public items; // 存储槽 1
constructor(string memory _version) {
version = _version;
}
function setItem(bytes32 key, bytes32 value) external {
items[key] = value;
emit ItemSet(key, value);
}
}
可以用 Remix 编译部署到 Sepolia,也可以用 3.15.4 节“部署合约”中的 Go 代码部署。拿到合约地址后,替换下面开场白里的
storeAddress。
第四步:所有示例共用的“开场白”
为了让每个示例尽量短,后面的代码片段都默认已经执行过这段初始化:
package main
import (
"context"
"fmt"
"log"
"math/big"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/ethclient/gethclient"
"github.com/ethereum/go-ethereum/rpc"
)
var (
ctx = context.Background()
// Sepolia 测试网上的示例数据
blockNumber = big.NewInt(5671744) // 该区块有 70 笔交易,时间戳 1712798400
blockHash = common.HexToHash("0xae713dea1419ac72b928ebe6ba9915cd4fc1ef125a606f90f5e783c47cb1a4b5")
txHash = common.HexToHash("0x20294a03e8766e9aeab58327fc4112756017c6c28f6f99c7722f4a29075601c5") // 该区块第 0 笔交易
account = common.HexToAddress("0x25836239F7b632635F815689389C537133248edb")
storeAddress = common.HexToAddress("0x2958d15bc5b64b11Ec65e623Ac50C198519f8742") // 已部署的 Store 合约,可换成你自己部署的
)
func main() {
client, err := ethclient.Dial("https://eth-sepolia.g.alchemy.com/v2/<API_KEY>")
if err != nil {
log.Fatal(err)
}
defer client.Close()
rpcClient := client.Client() // 底层 rpc.Client:调用 ethclient 没封装的方法
geth := gethclient.New(rpcClient) // Geth 扩展客户端
_, _ = rpcClient, geth
// ……把下面各小节的示例代码粘贴到这里运行
}
调用 ethclient 没封装的方法时,统一使用:
var result 结果类型 err := rpcClient.CallContext(ctx, &result, "方法名", 参数1, 参数2, ...)参数会被自动编码成 JSON;区块号这类 QUANTITY 记得用
hexutil.EncodeBig(n)或hexutil.Uint64(n)转成十六进制。后文用到的其他包:
bind统一指github.com/ethereum/go-ethereum/accounts/abi/bind/v2(新版合约绑定运行时),abi指github.com/ethereum/go-ethereum/accounts/abi,crypto指github.com/ethereum/go-ethereum/crypto。
3.1 总览速查表
方法清单怎么来的:以
ethereum.org 官方 JSON-RPC 文档 列出的标准方法为基准,合并
ethereum-json-rpc.com 收录的扩展方法(
debug_、trace_、erigon_等),以及ethereum/execution-apis 规范中的新增方法和 Geth 常用扩展,共 83 个,按功能分组。参数与语义以官方文档、execution-apis 规范和
Geth 官方文档 为准。
- Go 调用方式(go-ethereum v1.17.8):
client是ethclient.Client,geth是gethclient.Client,wsClient是用wss://地址创建的ethclient.Client。ethclient 没有现成函数的方法,用底层的rpcClient := client.Client()按方法名直接调用:rpcClient.CallContext(ctx, &result, "方法名", 参数...),其中result是用来接收返回值的变量。“不支持”表示 Geth v1.17.8 没有实现这个方法。- TS 调用方式(ethers.js v6):
provider是new ethers.JsonRpcProvider(url),wsProvider是new ethers.WebSocketProvider(wssUrl),wallet是new ethers.Wallet(privateKey, provider),contract是new ethers.Contract(address, abi, provider)。ethers 没有现成函数的方法,同样可以按方法名直接调用:provider.send("方法名", [参数...])。
链与费用(3.2)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 获取链 ID | eth_chainId | client.ChainID(ctx) | (await provider.getNetwork()).chainId | 当前链的 ID(主网 1,Sepolia 11155111) |
| 获取最新区块号 | eth_blockNumber | client.BlockNumber(ctx) | provider.getBlockNumber() | 最新区块号 |
| 获取建议 gas 价格 | eth_gasPrice | client.SuggestGasPrice(ctx) | (await provider.getFeeData()).gasPrice | 建议的 gas 价格 |
| 获取建议小费 | eth_maxPriorityFeePerGas | client.SuggestGasTipCap(ctx) | (await provider.getFeeData()).maxPriorityFeePerGas | 建议的小费(EIP-1559) |
| 获取 blob 基础费 | eth_blobBaseFee | client.BlobBaseFee(ctx) | provider.send("eth_blobBaseFee", []) | 当前 blob 基础费(EIP-4844) |
| 获取费用历史 | eth_feeHistory | client.FeeHistory(ctx, 5, nil, []float64{25, 50, 75}) | provider.send("eth_feeHistory", ["0x5", "latest", [25, 50, 75]]) | 最近若干区块的费用历史 |
账户与状态(3.3)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 获取余额 | eth_getBalance | client.BalanceAt(ctx, addr, blockNum) | provider.getBalance(address, blockTag) | 账户 ETH 余额 |
| 获取合约代码 | eth_getCode | client.CodeAt(ctx, addr, blockNum) | provider.getCode(address, blockTag) | 合约字节码 |
| 读取存储槽 | eth_getStorageAt | client.StorageAt(ctx, addr, slot, blockNum) | provider.getStorage(address, slot, blockTag) | 合约某个存储槽的原始值 |
| 只读调用合约 | eth_call | client.CallContract(ctx, msg, blockNum) | provider.call(tx) 或 contract.balanceOf(addr) | 只读调用合约,不上链 |
| 估算 Gas 消耗 | eth_estimateGas | client.EstimateGas(ctx, msg) | provider.estimateGas(tx) | 估算交易需要多少 gas |
| 查询事件日志 | eth_getLogs | client.FilterLogs(ctx, query) | provider.getLogs(filter) 或 contract.queryFilter(event) | 按条件查询事件日志 |
| 获取默克尔证明 | eth_getProof | geth.GetProof(ctx, addr, keys, blockNum) | provider.send("eth_getProof", [address, ["0x0"], "latest"]) | 账户/存储的默克尔证明 |
| 获取 nonce | eth_getTransactionCount | client.PendingNonceAt(ctx, addr) 或 client.NonceAt(ctx, addr, blockNum) | provider.getTransactionCount(address, "pending") | 账户的 nonce |
区块(3.4)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 按区块号查区块 | eth_getBlockByNumber | client.BlockByNumber(ctx, num) 或 client.HeaderByNumber(ctx, num) | provider.getBlock(blockNumber, prefetchTxs) | 按区块号查区块 |
| 按区块哈希查区块 | eth_getBlockByHash | client.BlockByHash(ctx, hash) 或 client.HeaderByHash(ctx, hash) | provider.getBlock(blockHash, prefetchTxs) | 按区块哈希查区块 |
| 按区块号统计交易数 | eth_getBlockTransactionCountByNumber | rpcClient.CallContext(ctx, &result, "eth_getBlockTransactionCountByNumber", blockNum)(pending 用 client.PendingTransactionCount(ctx)) | (await provider.getBlock(num)).transactions.length | 按区块号查交易数 |
| 按区块哈希统计交易数 | eth_getBlockTransactionCountByHash | client.TransactionCount(ctx, blockHash) | (await provider.getBlock(hash)).transactions.length | 按区块哈希查交易数 |
| 按区块号统计叔块数 | eth_getUncleCountByBlockNumber | rpcClient.CallContext(ctx, &result, "eth_getUncleCountByBlockNumber", blockNum) | provider.send("eth_getUncleCountByBlockNumber", [blockNum]) | 按区块号查叔块数 |
| 按区块哈希统计叔块数 | eth_getUncleCountByBlockHash | rpcClient.CallContext(ctx, &result, "eth_getUncleCountByBlockHash", blockHash) | provider.send("eth_getUncleCountByBlockHash", [blockHash]) | 按区块哈希查叔块数 |
交易与收据(3.5)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 按哈希查交易 | eth_getTransactionByHash | client.TransactionByHash(ctx, txHash) | provider.getTransaction(hash) | 按交易哈希查交易 |
| 按区块号和下标查交易 | eth_getTransactionByBlockNumberAndIndex | rpcClient.CallContext(ctx, &result, "eth_getTransactionByBlockNumberAndIndex", blockNum, index) | (await provider.getBlock(num)).getTransaction(i) | 按(区块号, 下标)查交易 |
| 按区块哈希和下标查交易 | eth_getTransactionByBlockHashAndIndex | client.TransactionInBlock(ctx, blockHash, i) | (await provider.getBlock(hash)).getTransaction(i) | 按(区块哈希, 下标)查交易 |
| 获取交易收据 | eth_getTransactionReceipt | client.TransactionReceipt(ctx, txHash) | provider.getTransactionReceipt(hash) | 交易收据(成功与否、日志、gas) |
| 按区块号和下标查叔块 | eth_getUncleByBlockNumberAndIndex | rpcClient.CallContext(ctx, &result, "eth_getUncleByBlockNumberAndIndex", blockNum, index) | provider.send("eth_getUncleByBlockNumberAndIndex", [blockNum, index]) | 按(区块号, 下标)查叔块 |
| 按区块哈希和下标查叔块 | eth_getUncleByBlockHashAndIndex | rpcClient.CallContext(ctx, &result, "eth_getUncleByBlockHashAndIndex", blockHash, index) | provider.send("eth_getUncleByBlockHashAndIndex", [blockHash, index]) | 按(区块哈希, 下标)查叔块 |
节点状态(3.6)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 获取节点托管账户 | eth_accounts | rpcClient.CallContext(ctx, &result, "eth_accounts") | provider.listAccounts() | 节点托管的账户列表 |
| 查询同步进度 | eth_syncing | client.SyncProgress(ctx) | provider.send("eth_syncing", []) | 节点同步进度 |
debug_ 交易追踪(3.7)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 模拟调用并追踪 | debug_traceCall | geth.TraceCallWithCallTracer(ctx, msg, block, nil, nil, cfg) | provider.send("debug_traceCall", [tx, "latest", { tracer: "callTracer" }]) | 模拟一次调用并输出执行轨迹 |
| 追踪已上链交易 | debug_traceTransaction | geth.TraceTransactionWithCallTracer(ctx, txHash, cfg) | provider.send("debug_traceTransaction", [txHash, { tracer: "callTracer" }]) | 重放一笔已上链交易并输出轨迹 |
| 按区块号追踪整块 | debug_traceBlockByNumber | rpcClient.CallContext(ctx, &result, "debug_traceBlockByNumber", blockNum, opts) | provider.send("debug_traceBlockByNumber", [blockNum, { tracer: "callTracer" }]) | 按区块号重放整块所有交易 |
| 按区块哈希追踪整块 | debug_traceBlockByHash | geth.TraceBlock(ctx, blockHash, cfg) | provider.send("debug_traceBlockByHash", [blockHash, { tracer: "callTracer" }]) | 按区块哈希重放整块所有交易 |
trace_ 追踪(3.8,⚠️ Geth 不支持)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 追踪整块调用 | trace_block | 不支持 | provider.send("trace_block", [blockTag])(需 Erigon 等节点) | 整块交易的调用轨迹(扁平结构) |
| 模拟调用并追踪 | trace_call | 不支持 | provider.send("trace_call", [tx, ["trace"], "latest"])(需 Erigon 等节点) | 模拟调用并追踪 |
| 按地址过滤调用轨迹 | trace_filter | 不支持 | provider.send("trace_filter", [{ fromBlock, toBlock, toAddress: [address] }])(需 Erigon 等节点) | 按 from/to 地址过滤调用轨迹 |
| 追踪未广播交易 | trace_rawTransaction | 不支持 | provider.send("trace_rawTransaction", [signedTx, ["trace"]])(需 Erigon 等节点) | 追踪一笔未广播的已签名交易 |
| 重放整块交易 | trace_replayBlockTransactions | 不支持 | provider.send("trace_replayBlockTransactions", [blockTag, ["stateDiff"]])(需 Erigon 等节点) | 重放整块交易,可要状态差异 |
| 重放单笔交易 | trace_replayTransaction | 不支持 | provider.send("trace_replayTransaction", [txHash, ["trace"]])(需 Erigon 等节点) | 重放单笔交易,可要状态差异 |
| 获取交易内部调用 | trace_transaction | 不支持 | provider.send("trace_transaction", [txHash])(需 Erigon 等节点) | 单笔交易的全部内部调用 |
erigon_ 扩展(3.9,⚠️ Geth 不支持)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 获取最新区块号 | erigon_blockNumber | 不支持 | provider.send("erigon_blockNumber", [])(需 Erigon 等节点) | 最新区块号(Erigon 版) |
| 按区块号取区块头 | erigon_getHeaderByNumber | 不支持 | provider.send("erigon_getHeaderByNumber", [blockNum])(需 Erigon 等节点) | 只取区块头(按号) |
| 按哈希取区块头 | erigon_getHeaderByHash | 不支持 | provider.send("erigon_getHeaderByHash", [blockHash])(需 Erigon 等节点) | 只取区块头(按哈希) |
| 按区块取全部日志 | erigon_getLogsByHash | 不支持 | provider.send("erigon_getLogsByHash", [blockHash])(需 Erigon 等节点) | 按区块哈希取该块所有日志 |
| 按时间戳找区块 | erigon_getBlockByTimestamp | 不支持 | provider.send("erigon_getBlockByTimestamp", [timestamp, false])(需 Erigon 等节点) | 按时间戳找区块 |
| 获取最新 N 条日志 | erigon_getLatestLogs | 不支持 | provider.send("erigon_getLatestLogs", [filter, { logCount: 10 }])(需 Erigon 等节点) | 取“最新 N 条”符合条件的日志 |
| 按区块取全部收据 | erigon_getBlockReceiptsByBlockHash | 不支持 | provider.send("erigon_getBlockReceiptsByBlockHash", [blockHash])(需 Erigon 等节点) | 按区块哈希取所有收据 |
发送与签名(3.10)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 发送已签名交易 | eth_sendRawTransaction | client.SendTransaction(ctx, signedTx) | provider.broadcastTransaction(signedTx) 或 wallet.sendTransaction(tx) | 广播已签名交易(所有写操作的出口) |
| 发送并等待收据 | eth_sendRawTransactionSync | client.SendTransactionSync(ctx, signedTx, &timeout) | provider.send("eth_sendRawTransactionSync", [signedTx, 30000]) | 发送并等待上链,直接返回收据(EIP-7966) |
| 节点签名并发送 | eth_sendTransaction | rpcClient.CallContext(ctx, &result, "eth_sendTransaction", txArgs)(需节点托管账户) | (await provider.getSigner()).sendTransaction(tx) | 让节点用托管的私钥签名并发送 |
| 节点签名消息 | eth_sign | rpcClient.CallContext(ctx, &result, "eth_sign", addr, data)(需节点托管账户) | provider.send("eth_sign", [address, data]) | 让节点对消息签名(EIP-191) |
| 节点签名交易 | eth_signTransaction | rpcClient.CallContext(ctx, &result, "eth_signTransaction", txArgs)(需节点托管账户) | (await provider.getSigner()).signTransaction(tx) | 让节点签名交易但不发送 |
| 节点补全交易字段 | eth_fillTransaction | rpcClient.CallContext(ctx, &result, "eth_fillTransaction", txArgs) | provider.send("eth_fillTransaction", [tx]) | 让节点补全 nonce、gas、费用等字段 |
过滤器与订阅(3.11)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 创建日志过滤器 | eth_newFilter | rpcClient.CallContext(ctx, &result, "eth_newFilter", filter) | provider.send("eth_newFilter", [filter]) | 登记日志过滤器 |
| 创建新区块过滤器 | eth_newBlockFilter | rpcClient.CallContext(ctx, &result, "eth_newBlockFilter") | provider.send("eth_newBlockFilter", []) | 登记新区块过滤器 |
| 创建新交易过滤器 | eth_newPendingTransactionFilter | rpcClient.CallContext(ctx, &result, "eth_newPendingTransactionFilter") | provider.send("eth_newPendingTransactionFilter", []) | 登记新交易过滤器 |
| 轮询过滤器增量 | eth_getFilterChanges | rpcClient.CallContext(ctx, &result, "eth_getFilterChanges", filterID) | provider.send("eth_getFilterChanges", [filterId]) | 取过滤器上次以来的新数据(增量) |
| 获取过滤器全部日志 | eth_getFilterLogs | rpcClient.CallContext(ctx, &result, "eth_getFilterLogs", filterID) | provider.send("eth_getFilterLogs", [filterId]) | 取日志过滤器条件下的全部日志(全量) |
| 删除过滤器 | eth_uninstallFilter | rpcClient.CallContext(ctx, &result, "eth_uninstallFilter", filterID) | provider.send("eth_uninstallFilter", [filterId]) | 注销过滤器 |
| 订阅推送 | eth_subscribe | wsClient.SubscribeNewHead(ctx, ch) 或 wsClient.SubscribeFilterLogs(ctx, q, ch) | wsProvider.on("block", cb) 或 contract.on("ItemSet", cb) | WebSocket 订阅新区块、日志、交易等 |
| 取消订阅 | eth_unsubscribe | sub.Unsubscribe() | wsProvider.off("block", cb) | 取消订阅 |
节点与客户端信息(3.12)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 获取客户端版本 | web3_clientVersion | rpcClient.CallContext(ctx, &result, "web3_clientVersion") | provider.send("web3_clientVersion", []) | 节点软件名称与版本 |
| 计算 Keccak-256 | web3_sha3 | rpcClient.CallContext(ctx, &result, "web3_sha3", data)(本地用 crypto.Keccak256) | provider.send("web3_sha3", [data])(本地用 ethers.keccak256) | 计算 Keccak-256 |
| 获取网络 ID | net_version | client.NetworkID(ctx) | provider.send("net_version", []) | 网络 ID |
| 查询 P2P 监听状态 | net_listening | rpcClient.CallContext(ctx, &result, "net_listening") | provider.send("net_listening", []) | 是否在监听 P2P 连接 |
| 获取对等节点数 | net_peerCount | client.PeerCount(ctx) | provider.send("net_peerCount", []) | 已连接的对等节点数 |
| 获取硬分叉配置 | eth_config | rpcClient.CallContext(ctx, &result, "eth_config") | provider.send("eth_config", []) | 当前与下一个硬分叉的配置(EIP-7910) |
| 查询节点历史数据范围 | eth_capabilities | rpcClient.CallContext(ctx, &result, "eth_capabilities") | provider.send("eth_capabilities", []) | 节点能提供哪些历史数据 |
状态与执行增强(3.13)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 获取整块收据 | eth_getBlockReceipts | client.BlockReceipts(ctx, blockNrOrHash) | provider.send("eth_getBlockReceipts", [blockTag]) | 一次取整块的所有收据 |
| 批量读取存储槽 | eth_getStorageValues | rpcClient.CallContext(ctx, &result, "eth_getStorageValues", requests, "latest") | provider.send("eth_getStorageValues", [{ [address]: [slot] }, "latest"]) | 批量读多个合约的多个存储槽 |
| 获取下一块 baseFee | eth_baseFee | rpcClient.CallContext(ctx, &result, "eth_baseFee") | provider.send("eth_baseFee", []) | 下一个区块的 baseFee |
| 生成访问列表 | eth_createAccessList | geth.CreateAccessList(ctx, msg) | provider.send("eth_createAccessList", [tx, "latest"]) | 生成 EIP-2930 访问列表 |
| 多笔调用模拟执行 | eth_simulateV1 | client.SimulateV1(ctx, opts, &block) | provider.send("eth_simulateV1", [{ blockStateCalls: [{ calls: [tx] }] }, "latest"]) | 多区块、多调用的模拟执行 |
| 获取区块访问列表 | eth_getBlockAccessList | 不支持 | provider.send("eth_getBlockAccessList", [blockTag]) | 区块访问列表(EIP-7928) |
debug* 原始数据与 txpool* 交易池(3.14)
| 操作功能 | JSON-RPC 方法 | Go 调用方式 | TS 调用方式 | 一句话说明 |
|---|---|---|---|---|
| 获取区块头原始编码 | debug_getRawHeader | rpcClient.CallContext(ctx, &result, "debug_getRawHeader", blockNum) | provider.send("debug_getRawHeader", [blockTag]) | 区块头的 RLP 原始编码 |
| 获取区块原始编码 | debug_getRawBlock | rpcClient.CallContext(ctx, &result, "debug_getRawBlock", blockNum) | provider.send("debug_getRawBlock", [blockTag]) | 整个区块的 RLP 原始编码 |
| 获取交易原始编码 | debug_getRawTransaction | rpcClient.CallContext(ctx, &result, "debug_getRawTransaction", txHash) | provider.send("debug_getRawTransaction", [txHash]) | 交易的原始编码 |
| 获取收据原始编码 | debug_getRawReceipts | rpcClient.CallContext(ctx, &result, "debug_getRawReceipts", blockNum) | provider.send("debug_getRawReceipts", [blockTag]) | 区块所有收据的原始编码 |
| 查看坏区块 | debug_getBadBlocks | rpcClient.CallContext(ctx, &result, "debug_getBadBlocks") | provider.send("debug_getBadBlocks", []) | 节点最近拒收的坏区块 |
| 获取区块访问列表原始编码 | debug_getRawBlockAccessList | 不支持 | provider.send("debug_getRawBlockAccessList", [blockTag]) | 区块访问列表的原始编码 |
| 查看交易池数量 | txpool_status | rpcClient.CallContext(ctx, &result, "txpool_status") | provider.send("txpool_status", []) | 交易池中 pending / queued 的数量 |
| 查看交易池全部交易 | txpool_content | rpcClient.CallContext(ctx, &result, "txpool_content") | provider.send("txpool_content", []) | 交易池全部交易 |
| 查看某地址的待处理交易 | txpool_contentFrom | rpcClient.CallContext(ctx, &result, "txpool_contentFrom", addr) | provider.send("txpool_contentFrom", [address]) | 交易池中某地址的交易 |
| 查看交易池摘要 | txpool_inspect | rpcClient.CallContext(ctx, &result, "txpool_inspect") | provider.send("txpool_inspect", []) | 交易池交易的文字摘要 |
trace_和erigon_不在以太坊标准规范里,Geth 也没有实现,调用 Geth 节点会得到-32601 method not found。需要时请连接 Erigon / Reth / Nethermind 节点,或开通了相应套餐的服务商。在 Go 里依然可以用rpcClient.CallContext、在 TS 里用provider.send调用,3.8、3.9 同样给出了示例。带“需节点托管账户”的签名类方法,只有节点本地保存了私钥(如
geth --dev,或通过--signer接入 Clef)时才能使用,详见 3.10。
3.2 链与费用类
1. eth_chainId —— 我连的是哪条链?
通俗解释:每条 EVM 链都有唯一编号,签名交易时必须带上它(EIP-155),防止你在测试网签的交易被拿到主网“重放”。
| 参数 | 无 |
|---|---|
| 返回 | QUANTITY,如 "0xaa36a7"(= 11155111,Sepolia) |
chainID, err := client.ChainID(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(chainID) // 11155111
实战场景:签名交易时取链 ID
chainID, _ := client.ChainID(ctx) signer := types.LatestSignerForChainID(chainID) // 自动适配 Legacy、EIP-1559 等所有交易类型 signedTx, err := types.SignTx(tx, signer, privateKey)也有不少代码用
client.NetworkID(对应net_version)来取这个值。主网和大多数测试网上两者数值相同,但签名时规范的做法是用ChainID。
2. eth_blockNumber —— 链现在长到多高了?
| 参数 | 无 |
|---|---|
| 返回 | QUANTITY,最新区块号 |
n, err := client.BlockNumber(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println("最新区块号:", n)
3. eth_gasPrice —— 现在发交易大概要多少 gas 价?
通俗解释:节点根据最近区块的成交价给出的“参考价”(单位 wei)。对传统(Legacy)交易,它就是 gasPrice;对 EIP-1559 交易,它约等于 baseFee + 建议小费。
| 参数 | 无 |
|---|---|
| 返回 | QUANTITY,单位 wei |
gasPrice, err := client.SuggestGasPrice(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println("建议 gasPrice(wei):", gasPrice)
实战场景:组装一笔 Legacy 转账交易
gasPrice, _ := client.SuggestGasPrice(ctx) to := common.HexToAddress("0x4592d8f8d7b001e72cb26a73e4fa1806a51ac79d") // 参数依次是:nonce、收款地址、金额(wei)、gasLimit、gasPrice、data tx := types.NewTransaction(nonce, to, big.NewInt(1e18), 21000, gasPrice, nil) // 普通 ETH 转账固定消耗 21000 gas
4. eth_maxPriorityFeePerGas —— 给打包者多少“小费”合适?
通俗解释:EIP-1559 之后,交易费 = baseFee(被销毁,由网络决定)+ priorityFee(小费,给出块者)。这个方法返回建议的小费。
| 参数 | 无 |
|---|---|
| 返回 | QUANTITY,单位 wei |
tip, err := client.SuggestGasTipCap(ctx)
if err != nil {
log.Fatal(err)
}
// 组装一笔 EIP-1559 交易时常见的写法:maxFee = 2 * baseFee + tip
header, _ := client.HeaderByNumber(ctx, nil)
maxFee := new(big.Int).Add(new(big.Int).Mul(header.BaseFee, big.NewInt(2)), tip)
fmt.Println("tip:", tip, "maxFee:", maxFee)
5. eth_blobBaseFee —— blob 数据现在多少钱?
通俗解释:坎昆升级(EIP-4844)引入了 blob 交易,专门给 L2 存放便宜的临时数据。blob 有自己独立的“基础费”市场,这个方法返回它。
| 参数 | 无 |
|---|---|
| 返回 | QUANTITY,每单位 blob gas 的价格(wei) |
blobFee, err := client.BlobBaseFee(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println("blob base fee:", blobFee)
6. eth_feeHistory —— 最近几个区块的“油价走势图”
| 参数 | 类型 | 说明 |
|---|---|---|
blockCount | QUANTITY | 要看最近多少个区块(最多 1024) |
newestBlock | 区块号/标签 | 从哪个区块往前数,常用 "latest" |
rewardPercentiles | []float64 | 小费分位数,如 [25, 50, 75] 表示低/中/高三档 |
| 返回字段 | 说明 |
|---|---|
oldestBlock | 统计窗口中最老的区块号 |
baseFeePerGas | 每个区块的 baseFee(比 blockCount 多一个,最后一个是对下一块的预测) |
gasUsedRatio | 每个区块的 gas 使用率(0~1) |
reward | 每个区块中各分位的小费 |
// 最近 5 个区块,看 25% / 50% / 75% 分位的小费
fh, err := client.FeeHistory(ctx, 5, nil, []float64{25, 50, 75})
if err != nil {
log.Fatal(err)
}
fmt.Println("起始区块:", fh.OldestBlock)
for i, ratio := range fh.GasUsedRatio {
fmt.Printf("块 %d: baseFee=%v 使用率=%.2f 小费=%v\n",
new(big.Int).Add(fh.OldestBlock, big.NewInt(int64(i))), fh.BaseFee[i], ratio, fh.Reward[i])
}
钱包里“慢 / 标准 / 快”三档手续费,很多就是用
eth_feeHistory的分位数算出来的。
3.3 账户与状态类
7. eth_getBalance —— 查余额
| 参数 | 类型 | 说明 |
|---|---|---|
address | DATA, 20 字节 | 要查询的地址 |
block | 区块号/标签 | 在哪个区块时的余额 |
| 返回 | QUANTITY,单位 wei |
|---|
// 最新余额:区块号传 nil 即 "latest"
balance, err := client.BalanceAt(ctx, account, nil)
if err != nil {
log.Fatal(err)
}
// 历史余额:区块 5532993 时的余额(需要节点保留了该历史状态,见下方提示)
old, _ := client.BalanceAt(ctx, account, big.NewInt(5532993))
// 待定余额:"pending"
pending, _ := client.PendingBalanceAt(ctx, account)
// wei → ETH
eth := new(big.Float).Quo(new(big.Float).SetInt(balance), big.NewFloat(1e18))
fmt.Println(balance, old, pending, eth)
实战场景:余额展示:
BalanceAt返回的是*big.Int,单位 wei。展示给用户时要除以 10^18,并且用big.Float计算,避免float64丢精度。例如25729324269165216042wei 显示为25.729324269165216042ETH。常见误区:普通全节点只保留最近约 128 个区块的完整状态,查很久以前的余额会报
missing trie node,需要归档节点(archive node)。这和第四部分讲的状态树(Trie)存储方式直接相关。
8. eth_getCode —— 这个地址是合约吗?
| 参数 | 类型 | 说明 |
|---|---|---|
address | DATA, 20 字节 | 地址 |
block | 区块号/标签 | 区块 |
| 返回 | DATA,运行时字节码;普通账户(EOA)返回 "0x" |
|---|
code, err := client.CodeAt(ctx, storeAddress, nil)
if err != nil {
log.Fatal(err)
}
if len(code) == 0 {
fmt.Println("这是一个普通账户(EOA)")
} else {
fmt.Printf("这是合约,字节码长度 %d 字节\n", len(code))
}
实战场景:确认合约部署成功
receipt, _ := bind.WaitMined(ctx, client, signedTx.Hash()) // 等部署交易被打包(bind 指 accounts/abi/bind/v2) code, _ := client.CodeAt(ctx, receipt.ContractAddress, nil) // 查新合约地址上的代码 fmt.Println("部署成功?", receipt.Status == types.ReceiptStatusSuccessful && len(code) > 0)注意:这里拿到的是运行时字节码,比部署时发送的字节码(含构造函数)要短,第四部分 4.10 会解释原因。部署合约的完整代码见 3.15.4。
9. eth_getStorageAt —— 直接读合约的“抽屉”
通俗解释:合约的状态变量存放在一排编号从 0 开始、每格 32 字节的“抽屉”(storage slot)里。这个方法能绕过合约的 getter,直接读某一格的原始数据。
| 参数 | 类型 | 说明 |
|---|---|---|
address | DATA, 20 字节 | 合约地址 |
position | QUANTITY / 32 字节 | 槽位编号 |
block | 区块号/标签 | 区块 |
| 返回 | DATA,32 字节 |
|---|
以 3.0 节的 Store 合约为例:string public version 在槽 0,mapping(bytes32 => bytes32) items 在槽 1。
import "github.com/ethereum/go-ethereum/crypto"
// ① 读槽 0:短字符串(<32 字节)的布局是“内容左对齐 + 最后一个字节 = 长度*2”
slot0, _ := client.StorageAt(ctx, storeAddress, common.Hash{}, nil)
strLen := int(slot0[31]) / 2
fmt.Println("version =", string(slot0[:strLen])) // 例如 "1.0"
// ② 读 mapping:items[key] 的槽位 = keccak256(key ++ uint256(1))
var key [32]byte
copy(key[:], []byte("demo_save_key"))
slotIndex := common.LeftPadBytes(big.NewInt(1).Bytes(), 32)
slot := crypto.Keccak256Hash(key[:], slotIndex)
value, _ := client.StorageAt(ctx, storeAddress, slot, nil)
fmt.Printf("items[key] = %x\n", value)
这个结果和下一节用
eth_call调用 getteritems(key)拿到的值一致:一个是让 EVM 执行 getter 函数,一个是直接读存储。
10. eth_call —— 调用合约的只读函数
通俗解释:让节点在本地“假装执行”一次调用并返回结果,不签名、不花 gas、不上链。所有 view / pure 函数都靠它,abigen 生成的只读方法底层也是它。
| 参数 | 类型 | 说明 |
|---|---|---|
transaction | 对象 | from(可选)、to(合约地址)、gas、gasPrice、value、data(ABI 编码的调用数据) |
block | 区块号/标签 | 基于哪个区块的状态执行 |
stateOverrides(Geth 扩展,可选) | 对象 | 临时覆盖某些账户的余额、代码、存储再执行 |
| 返回 | DATA,函数返回值(ABI 编码) |
|---|
import (
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
)
// 只需要用到的那部分 ABI
storeABI, _ := abi.JSON(strings.NewReader(`[{"inputs":[{"name":"","type":"bytes32"}],"name":"items","outputs":[{"name":"","type":"bytes32"}],"stateMutability":"view","type":"function"}]`))
var key [32]byte
copy(key[:], []byte("demo_save_key"))
data, _ := storeABI.Pack("items", key) // 函数选择器(4 字节) + 参数(32 字节)
out, err := client.CallContract(ctx, ethereum.CallMsg{To: &storeAddress, Data: data}, nil)
if err != nil {
log.Fatal(err) // 合约 revert 时这里会返回 "execution reverted"
}
var value [32]byte
_ = storeABI.UnpackIntoInterface(&value, "items", out)
fmt.Printf("items[key] = %x\n", value)
实战场景:查 ERC-20 代币余额(不用 abigen)
tokenAddress := common.HexToAddress("<ERC-20 合约地址>") selector := crypto.Keccak256([]byte("balanceOf(address)"))[:4] // 函数选择器 0x70a08231 data := append(selector, common.LeftPadBytes(account.Bytes(), 32)...) // 参数补齐到 32 字节 out, _ := client.CallContract(ctx, ethereum.CallMsg{To: &tokenAddress, Data: data}, nil) fmt.Println("余额(最小单位):", new(big.Int).SetBytes(out))实战场景:用 abigen 生成的合约代码查询。先把合约 ABI 生成 Go 代码:用
solcjs --abi IERC20Metadata.sol得到 ABI 文件,再执行abigen --v2 --abi=<abi文件> --type=Token --pkg=token --out=erc20.go。--v2生成的是 go-ethereum 新版推荐的绑定代码,配套运行时是github.com/ethereum/go-ethereum/accounts/abi/bind/v2(旧的accounts/abi/bind已标记为 Deprecated)。erc20 := token.NewToken() // 绑定对象:负责 ABI 编解码 instance := erc20.Instance(client, tokenAddress) // 绑定到链上某个地址的合约实例 opts := &bind.CallOpts{Context: ctx} // bind.Call = 打包参数 → eth_call → 解包返回值 bal, _ := bind.Call(instance, opts, erc20.PackBalanceOf(account), erc20.UnpackBalanceOf) decimals, _ := bind.Call(instance, opts, erc20.PackDecimals(), erc20.UnpackDecimals) symbol, _ := bind.Call(instance, opts, erc20.PackSymbol(), erc20.UnpackSymbol) human := new(big.Float).Quo(new(big.Float).SetInt(bal), big.NewFloat(math.Pow10(int(decimals)))) fmt.Printf("%s %s\n", human.Text('f', 6), symbol)
11. eth_estimateGas —— 这笔交易要烧多少 gas?
通俗解释:节点用二分查找反复模拟执行交易,找出能让它成功的最小 gas 量。如果交易注定失败(比如余额不够、合约 revert),这里就会提前报错——是发交易前非常好的“体检”。
| 参数 | 类型 | 说明 |
|---|---|---|
transaction | 对象 | 同 eth_call,from 建议填写(余额、权限检查都和它有关) |
block(可选) | 区块号/标签 | 默认 "latest" |
| 返回 | QUANTITY,估算的 gas 数量 |
|---|
// 代币转账的 data = 函数选择器(4 字节) + 收款地址(补齐 32 字节) + 金额(补齐 32 字节)
toAddress := common.HexToAddress("0x4592d8f8d7b001e72cb26a73e4fa1806a51ac79d") // 代币收款人
tokenAddress := common.HexToAddress("<ERC-20 合约地址>")
methodID := crypto.Keccak256([]byte("transfer(address,uint256)"))[:4] // 0xa9059cbb
amount, _ := new(big.Int).SetString("1000000000000000000000", 10) // 1000 个代币(18 位小数)
var data []byte
data = append(data, methodID...)
data = append(data, common.LeftPadBytes(toAddress.Bytes(), 32)...)
data = append(data, common.LeftPadBytes(amount.Bytes(), 32)...)
gasLimit, err := client.EstimateGas(ctx, ethereum.CallMsg{
From: fromAddress, // 发送方
To: &tokenAddress, // 注意:To 是“代币合约地址”,不是收款人地址
Data: data,
})
if err != nil {
log.Fatal(err)
}
fmt.Println("预计 gas:", gasLimit)
常见误区:估算代币转账的 gas 时,
To必须填代币合约地址,而不是收款人地址,并且要填上From。如果误填成收款人(普通账户),估出来的只是“往普通地址带一段 data 转 0 ETH”的成本(2 万出头),并不是执行transfer的真实成本,交易发出去会因 gas 不足而失败。实际使用时建议在估算结果上再乘 1.1~1.2 留点余量。完整的代币转账代码见 3.15.3。
12. eth_getLogs —— 查询合约事件
通俗解释:合约 emit 的事件会作为“日志”记录在交易收据里。这个方法就像在全链日志上做一次带条件的 SQL 查询。
| 参数(一个过滤对象) | 类型 | 说明 |
|---|---|---|
fromBlock / toBlock | 区块号/标签 | 区块范围(和 blockHash 二选一) |
blockHash | DATA, 32 字节 | 只查某一个区块 |
address | 地址或地址数组 | 只要这些合约发出的日志 |
topics | 数组 | 按主题过滤,见下方说明 |
topics 的规则(位置对应,同一位置内是“或”):
topics[0] = 事件签名哈希,如 keccak256("ItemSet(bytes32,bytes32)")
topics[1..3] = 被 indexed 修饰的参数
[[A], [], [B, C]] 表示:topic0 == A 并且 topic1 随意 并且 topic2 == B 或 C
import "github.com/ethereum/go-ethereum/crypto"
itemSetSig := crypto.Keccak256Hash([]byte("ItemSet(bytes32,bytes32)"))
query := ethereum.FilterQuery{
FromBlock: big.NewInt(6920583),
ToBlock: big.NewInt(6921583), // 范围别太大,很多服务商限制在 1 万块以内
Addresses: []common.Address{storeAddress},
Topics: [][]common.Hash{{itemSetSig}}, // 只要 ItemSet 事件
}
logs, err := client.FilterLogs(ctx, query)
if err != nil {
log.Fatal(err)
}
for _, l := range logs {
fmt.Println(l.BlockNumber, l.TxHash.Hex(), l.Topics[0].Hex())
}
实战场景:解码事件。日志里的数据是 ABI 编码的,要用合约 ABI 解开。被
indexed修饰的参数放在Topics里,其余参数放在Data里:// StoreABI:Store 合约的 ABI JSON 字符串(solcjs --abi Store.sol 生成) contractAbi, _ := abi.JSON(strings.NewReader(StoreABI)) for _, l := range logs { var ev struct{ Value [32]byte } // 非 indexed 参数 value 在 Data 里 _ = contractAbi.UnpackIntoInterface(&ev, "ItemSet", l.Data) key := l.Topics[1] // indexed 参数 key 在 Topics[1] fmt.Printf("区块 %d:key=%x value=%x\n", l.BlockNumber, key, ev.Value) }一个事件最多 4 个 topic:
Topics[0]固定是事件签名哈希,后面最多 3 个是indexed参数,被索引的参数不会再出现在 Data 里。节点之所以能在海量区块中快速找到日志,靠的是日志索引,第四部分 4.9 会讲。
13. eth_getProof —— 拿一份“我确实有这么多钱”的数学证明
通俗解释:返回账户(以及它的若干存储槽)在状态树中的默克尔证明。拿着区块头里的 stateRoot,任何人都能离线验证“这个账户在这个区块时的余额、nonce、存储值就是这些”,不需要信任节点。轻客户端、跨链桥、L2 都靠它。
| 参数 | 类型 | 说明 |
|---|---|---|
address | DATA, 20 字节 | 账户地址 |
storageKeys | DATA 数组 | 要证明的存储槽 |
block | 区块号/标签 | 区块 |
| 返回字段 | 说明 |
|---|---|
balance / nonce / codeHash / storageHash | 账户的四个字段 |
accountProof | 从 stateRoot 到该账户的一串 RLP 编码节点 |
storageProof | 每个存储槽的 key、value、proof |
// ethclient 没有封装,用 gethclient
res, err := geth.GetProof(ctx, storeAddress, []string{"0x0"}, nil)
if err != nil {
log.Fatal(err)
}
fmt.Println("余额:", res.Balance, "nonce:", res.Nonce)
fmt.Println("账户证明节点数:", len(res.AccountProof))
fmt.Println("槽 0 的值:", res.StorageProof[0].Value)
证明里每一个节点都是 Trie 的一个节点,经过 RLP 编码——这正是第四部分 4.3(RLP)和 4.4(Trie)要讲的内容。
14. eth_getTransactionCount —— 查 nonce
通俗解释:nonce 是账户“已经发出多少笔交易”的计数器,也是下一笔交易必须使用的序号。它保证交易按顺序执行、不能被重放。
| 参数 | 类型 | 说明 |
|---|---|---|
address | DATA, 20 字节 | 地址 |
block | 区块号/标签 | "latest" = 已上链的数量;"pending" = 算上交易池中还没打包的 |
| 返回 | QUANTITY |
|---|
confirmed, _ := client.NonceAt(ctx, account, nil) // "latest"
next, err := client.PendingNonceAt(ctx, account) // "pending":发新交易用这个
if err != nil {
log.Fatal(err)
}
fmt.Println("已上链:", confirmed, "下一笔应使用:", next)
实战场景:连续发多笔交易。转账、部署合约、调用合约之前都要先取 nonce。连续发多笔时,不要每笔都重新查(上一笔可能还没进交易池,会查到重复的 nonce),而是在本地自增:
nonce, _ := client.PendingNonceAt(ctx, fromAddress) tx1 := types.NewTransaction(nonce, to, value, 21000, gasPrice, nil) tx2 := types.NewTransaction(nonce+1, to, value, 21000, gasPrice, nil) // 第二笔直接 +1
3.4 区块类
15. eth_getBlockByNumber —— 按高度查区块
| 参数 | 类型 | 说明 |
|---|---|---|
block | 区块号/标签 | 如 "0x568b40" 或 "latest" |
fullTx | Boolean | true 返回完整交易对象;false 只返回交易哈希 |
| 返回 | 区块对象(number、hash、parentHash、timestamp、baseFeePerGas、stateRoot、transactions 等),不存在时返回 null |
|---|
// 只要区块头:底层调用 eth_getBlockByNumber(n, false),数据量小
header, err := client.HeaderByNumber(ctx, blockNumber)
if err != nil {
log.Fatal(err)
}
fmt.Println(header.Number, header.Time, header.Hash().Hex())
// 完整区块:底层调用 eth_getBlockByNumber(n, true)
block, err := client.BlockByNumber(ctx, blockNumber)
if err != nil {
log.Fatal(err)
}
fmt.Println("交易数:", len(block.Transactions())) // 70
小技巧:只需要高度、时间戳、哈希时,优先用
HeaderByNumber,省流量。实战场景:遍历区块里的交易
for _, tx := range block.Transactions() { fmt.Println(tx.Hash().Hex(), tx.Value(), tx.Gas(), tx.GasPrice(), tx.Nonce(), tx.To()) }
16. eth_getBlockByHash —— 按哈希查区块
| 参数 | 类型 | 说明 |
|---|---|---|
blockHash | DATA, 32 字节 | 区块哈希 |
fullTx | Boolean | 同上 |
block, err := client.BlockByHash(ctx, blockHash)
if err != nil {
log.Fatal(err)
}
fmt.Println(block.Number(), len(block.Transactions()))
header, _ := client.HeaderByHash(ctx, blockHash) // 只要区块头
fmt.Println(header.ParentHash.Hex())
实战场景:订阅到新区块头后取完整区块(订阅需要
wss://地址)headers := make(chan *types.Header) sub, _ := wsClient.SubscribeNewHead(ctx, headers) defer sub.Unsubscribe() for h := range headers { // 简化写法;生产代码要同时监听 sub.Err(),见 3.11 block, _ := wsClient.BlockByHash(ctx, h.Hash()) // 新区块头 → 完整区块 fmt.Println(block.Number(), len(block.Transactions())) }
17. eth_getBlockTransactionCountByNumber —— 按高度数交易
| 参数 | block:区块号/标签 |
|---|---|
| 返回 | QUANTITY,交易数量 |
// ethclient 只封装了 "pending" 版本
pendingCount, _ := client.PendingTransactionCount(ctx)
// 指定区块号:用 rpc 手动调用
var count hexutil.Uint
err := rpcClient.CallContext(ctx, &count, "eth_getBlockTransactionCountByNumber", hexutil.EncodeBig(blockNumber))
if err != nil {
log.Fatal(err)
}
fmt.Println("pending:", pendingCount, "区块 5671744:", uint(count)) // 70
18. eth_getBlockTransactionCountByHash —— 按哈希数交易
| 参数 | blockHash:DATA, 32 字节 |
|---|---|
| 返回 | QUANTITY |
count, err := client.TransactionCount(ctx, blockHash)
if err != nil {
log.Fatal(err)
}
fmt.Println(count) // 70
注意:
TransactionCount这个名字容易和“账户交易数(nonce)”混淆,它其实是“区块里的交易数”。
19 & 20. eth_getUncleCountByBlockNumber / eth_getUncleCountByBlockHash —— 叔块数量
通俗解释:PoW 时代,两个矿工几乎同时挖出区块,没被选中的那个如果被后续区块“引用”,就叫叔块(uncle / ommer),矿工也能拿到一部分奖励。合并转 PoS 后不再有叔块,新区块的结果永远是 0。
| 参数 | block(区块号/标签)或 blockHash(32 字节) |
|---|---|
| 返回 | QUANTITY |
var byNum, byHash hexutil.Uint
_ = rpcClient.CallContext(ctx, &byNum, "eth_getUncleCountByBlockNumber", hexutil.EncodeBig(big.NewInt(12965000))) // 伦敦升级时期的主网区块
_ = rpcClient.CallContext(ctx, &byHash, "eth_getUncleCountByBlockHash", blockHash)
fmt.Println(uint(byNum), uint(byHash)) // Sepolia 的 PoS 区块:0
3.5 交易与收据类
21. eth_getTransactionByHash —— 按哈希查交易
| 参数 | txHash:DATA, 32 字节 |
|---|---|
| 返回 | 交易对象(from、to、value、nonce、input、gas、blockNumber 等);还在交易池时 blockNumber 为 null;查不到返回 null |
tx, isPending, err := client.TransactionByHash(ctx, txHash)
if err != nil {
log.Fatal(err) // 查不到时返回 ethereum.NotFound
}
fmt.Println(tx.Hash().Hex(), tx.Value(), tx.Gas(), tx.Nonce(), isPending)
// 交易对象本身不存 from,需要用签名恢复
chainID, _ := client.ChainID(ctx)
from, _ := types.Sender(types.LatestSignerForChainID(chainID), tx)
fmt.Println("from:", from.Hex())
常见误区:很多教程用
types.NewEIP155Signer(chainID)恢复发送方,它只认 Legacy 交易,遇到 EIP-1559(type 2)交易会报错。推荐用types.LatestSignerForChainID,所有交易类型都能处理。另外,老教程里的tx.AsMessage(signer)在新版 go-ethereum 中已经移除,以types.Sender为准。
22. eth_getTransactionByBlockNumberAndIndex —— 第 N 块的第 i 笔交易
| 参数 | 类型 | 说明 |
|---|---|---|
block | 区块号/标签 | 区块 |
index | QUANTITY | 交易在区块中的下标,从 0 开始 |
var tx *types.Transaction // types.Transaction 能直接从 RPC 返回的 JSON 解码
err := rpcClient.CallContext(ctx, &tx, "eth_getTransactionByBlockNumberAndIndex",
hexutil.EncodeBig(blockNumber), hexutil.Uint(0))
if err != nil {
log.Fatal(err)
}
fmt.Println(tx.Hash().Hex()) // 0x20294a03...(区块 5671744 的第 0 笔交易)
23. eth_getTransactionByBlockHashAndIndex —— 某块(按哈希)的第 i 笔交易
| 参数 | 类型 | 说明 |
|---|---|---|
blockHash | DATA, 32 字节 | 区块哈希 |
index | QUANTITY | 下标 |
count, _ := client.TransactionCount(ctx, blockHash)
for i := uint(0); i < count; i++ {
tx, err := client.TransactionInBlock(ctx, blockHash, i)
if err != nil {
log.Fatal(err)
}
fmt.Println(i, tx.Hash().Hex())
}
实战场景:区块很大、又不想一次下载全部交易时,就用上面这种“先
TransactionCount取数量,再TransactionInBlock逐笔获取”的写法。
24. eth_getTransactionReceipt —— 交易收据(到底成没成功?)
通俗解释:交易发出去不等于执行成功。收据是交易被打包执行后的“回执单”,告诉你结果。
| 参数 | txHash:DATA, 32 字节 |
|---|
| 返回字段 | 说明 |
|---|---|
status | 1 成功,0 失败(revert) |
gasUsed / effectiveGasPrice | 实际消耗的 gas 和实际单价 → 手续费 = 两者相乘 |
logs | 本交易产生的所有事件 |
contractAddress | 部署合约的交易才有值:新合约地址 |
blockNumber / transactionIndex | 位于哪个区块的第几笔 |
receipt, err := client.TransactionReceipt(ctx, txHash)
if err != nil {
log.Fatal(err) // 还没被打包时返回 ethereum.NotFound
}
fmt.Println("成功?", receipt.Status == types.ReceiptStatusSuccessful)
fmt.Println("手续费(wei):", new(big.Int).Mul(receipt.EffectiveGasPrice, new(big.Int).SetUint64(receipt.GasUsed)))
fmt.Println("日志数:", len(receipt.Logs))
实战场景:发完交易后等待上链
// 写法一:自己轮询,还没打包时返回 ethereum.NotFound for { receipt, err := client.TransactionReceipt(ctx, signedTx.Hash()) if err == nil { fmt.Println("已上链,status =", receipt.Status, "新合约地址 =", receipt.ContractAddress.Hex()) break } if !errors.Is(err, ethereum.NotFound) { log.Fatal(err) } time.Sleep(time.Second) } // 写法二:用 bind.WaitMined 一行搞定(内部也是轮询收据,bind 指 accounts/abi/bind/v2) receipt, err := bind.WaitMined(ctx, client, signedTx.Hash())
receipt.ContractAddress只有部署合约的交易才有值,普通交易是全零地址。
25 & 26. eth_getUncleByBlockNumberAndIndex / eth_getUncleByBlockHashAndIndex —— 取叔块
| 参数 | 类型 | 说明 |
|---|---|---|
block 或 blockHash | 区块号/标签 或 32 字节 | 包含叔块的区块 |
index | QUANTITY | 叔块下标 |
| 返回 | 叔块的区块头(叔块没有交易),没有则返回 null |
|---|
var uncle *types.Header
err := rpcClient.CallContext(ctx, &uncle, "eth_getUncleByBlockNumberAndIndex",
hexutil.EncodeBig(big.NewInt(12965000)), hexutil.Uint(0))
if err != nil {
log.Fatal(err)
}
if uncle == nil {
fmt.Println("该区块没有叔块(PoS 区块一定没有)")
} else {
fmt.Println("叔块号:", uncle.Number, "矿工:", uncle.Coinbase.Hex())
}
// 按哈希版本,参数换成区块哈希即可
_ = rpcClient.CallContext(ctx, &uncle, "eth_getUncleByBlockHashAndIndex", blockHash, hexutil.Uint(0))
冷知识:
client.BlockByNumber在内部发现区块头的uncleHash不是空值时,会自动批量调用eth_getUncleByBlockHashAndIndex把叔块取回来,所以你平时感觉不到这两个方法的存在。
3.6 节点状态类
27. eth_accounts —— 节点替你保管的账户
通俗解释:返回节点本地钱包里的地址。公共 RPC 服务(Alchemy、Infura)不会替你保管私钥,所以永远返回 [];Hardhat / Anvil 这类本地开发链会返回预置的测试账户。
| 参数 | 无 |
|---|---|
| 返回 | 地址数组 |
var accounts []common.Address
if err := rpcClient.CallContext(ctx, &accounts, "eth_accounts"); err != nil {
log.Fatal(err)
}
fmt.Println(accounts) // 公共节点:[];npx hardhat node:20 个测试地址
实战场景:用
npx hardhat node或 Foundry 的anvil在本机启动开发链后,连接http://127.0.0.1:8545调用这个方法,就能拿到那批预分配了 ETH 的测试账户地址。
28. eth_syncing —— 节点同步完了吗?
| 参数 | 无 |
|---|---|
| 返回 | 已同步完成:false;同步中:对象(startingBlock、currentBlock、highestBlock 及状态同步进度等) |
progress, err := client.SyncProgress(ctx)
if err != nil {
log.Fatal(err)
}
if progress == nil {
fmt.Println("节点已同步到最新")
} else {
fmt.Printf("同步中:%d / %d\n", progress.CurrentBlock, progress.HighestBlock)
}
自己搭节点时,在同步完成前查到的余额、nonce 可能是旧的,后端服务上线前务必先检查这个方法。同步的内部机制见第四部分 4.8。
3.7 debug_ 交易追踪类(Geth)
前提:节点要开放
debug命名空间(--http.api eth,net,web3,debug)。公共服务商一般需要付费套餐。追踪历史交易需要节点有对应的历史状态(否则需要归档节点)。tracer 参数是这一类方法的灵魂:
tracer 输出内容 适合 不填(默认 structLogger) 每一条 EVM 操作码、栈、内存、gas 精细调试(输出非常大) "callTracer"内部调用树(谁调了谁、转了多少钱、是否 revert) 最常用:看懂一笔复杂交易 "prestateTracer"执行前涉及账户的状态;加 diffMode: true输出前后差异分析状态变化 "4byteTracer"调用到的函数选择器统计 统计分析
29. debug_traceCall —— 模拟执行并追踪(不上链)
| 参数 | 类型 | 说明 |
|---|---|---|
transaction | 对象 | 同 eth_call |
block | 区块号/标签 | 基于哪个区块 |
options | 对象 | tracer、tracerConfig、stateOverrides、blockOverrides 等 |
import "encoding/json"
var trace json.RawMessage
callArgs := map[string]any{
"to": storeAddress,
"data": hexutil.Bytes(data), // 比如 3.3 节中 Pack 出来的 items(key)
}
err := rpcClient.CallContext(ctx, &trace, "debug_traceCall", callArgs, "latest",
map[string]any{"tracer": "callTracer"})
if err != nil {
log.Fatal(err)
}
fmt.Println(string(trace)) // {"from":"0x0000...","gas":"0x...","to":"0x2958...","type":"CALL",...}
30. debug_traceTransaction —— 重放一笔已上链交易
通俗解释:交易失败了却不知道为什么?用它把交易原样重放一遍,看清每一步内部调用。
| 参数 | 类型 | 说明 |
|---|---|---|
txHash | DATA, 32 字节 | 交易哈希 |
options | 对象 | tracer、tracerConfig(如 {"onlyTopCall": true})、timeout 等 |
var result json.RawMessage
err := rpcClient.CallContext(ctx, &result, "debug_traceTransaction", txHash,
map[string]any{"tracer": "callTracer", "tracerConfig": map[string]any{"withLog": true}})
if err != nil {
log.Fatal(err)
}
fmt.Println(string(result))
gethclient 还提供了带类型的 callTracer 封装,不用自己解析 JSON:
frame, err := geth.TraceTransactionWithCallTracer(ctx, txHash, &gethclient.CallTracerConfig{WithLog: true})
if err != nil {
log.Fatal(err)
}
fmt.Println(frame.Type, frame.From.Hex(), frame.GasUsed, frame.Error) // 顶层调用
for _, sub := range frame.Calls { // 内部调用(区块浏览器的“内部交易”)
fmt.Println(" ->", sub.Type, sub.To, sub.Value)
}
31 & 32. debug_traceBlockByNumber / debug_traceBlockByHash —— 重放整块
| 参数 | 类型 | 说明 |
|---|---|---|
block 或 blockHash | 区块号/标签 或 32 字节 | 区块 |
options | 对象 | 同上 |
| 返回 | 数组,每笔交易一个 {txHash, result} |
|---|
var results []struct {
TxHash common.Hash `json:"txHash"`
Result json.RawMessage `json:"result"`
}
opts := map[string]any{"tracer": "callTracer"}
err := rpcClient.CallContext(ctx, &results, "debug_traceBlockByNumber", hexutil.EncodeBig(blockNumber), opts)
if err != nil {
log.Fatal(err)
}
fmt.Println("追踪到交易数:", len(results)) // 70
// 按哈希:
_ = rpcClient.CallContext(ctx, &results, "debug_traceBlockByHash", blockHash, opts)
区块浏览器里“内部交易(Internal Txns)”一栏,很多就是用
callTracer追踪出来的。
3.8 trace_ 追踪类(Parity 风格,Geth 不支持)
trace_系列最早由 Parity(OpenEthereum)实现,现在 Erigon、Nethermind、Reth 都支持。它和debug_trace*+callTracer的目标相近,但输出是扁平数组(每个内部调用一项,用traceAddress表示层级),更方便直接入库。下面的代码对支持
trace_的节点有效;对 Geth 节点会返回method not found。所有返回值都用json.RawMessage接收,按需再解析。
traceTypes 参数(trace_call、trace_rawTransaction、trace_replay* 共用):
| 值 | 含义 |
|---|---|
"trace" | 内部调用轨迹 |
"vmTrace" | 完整的 EVM 执行轨迹 |
"stateDiff" | 执行前后的状态差异 |
33. trace_block —— 整块的调用轨迹
| 参数 | block:区块号/标签 |
|---|
var traces json.RawMessage
err := rpcClient.CallContext(ctx, &traces, "trace_block", hexutil.EncodeBig(blockNumber))
fmt.Println(string(traces), err)
34. trace_call —— 模拟调用并追踪
| 参数 | 类型 | 说明 |
|---|---|---|
transaction | 对象 | 同 eth_call |
traceTypes | 字符串数组 | 见上表 |
block | 区块号/标签 | 区块 |
var out json.RawMessage
err := rpcClient.CallContext(ctx, &out, "trace_call",
map[string]any{"to": storeAddress, "data": hexutil.Bytes(data)},
[]string{"trace", "stateDiff"}, "latest")
fmt.Println(string(out), err)
35. trace_filter —— 按地址筛选调用轨迹
通俗解释:“把某个地址在这段区块里所有被调用、转账的痕迹找出来”,包括普通 eth_getLogs 查不到的内部转账。
| 参数(一个过滤对象) | 类型 | 说明 |
|---|---|---|
fromBlock / toBlock | 区块号/标签 | 范围 |
fromAddress | 地址数组 | 发起方 |
toAddress | 地址数组 | 接收方 |
after | 整数 | 结果偏移量(分页) |
count | 整数 | 本次最多返回多少条 |
var out json.RawMessage
err := rpcClient.CallContext(ctx, &out, "trace_filter", map[string]any{
"fromBlock": hexutil.EncodeBig(big.NewInt(6920583)),
"toBlock": hexutil.EncodeBig(big.NewInt(6921583)),
"toAddress": []common.Address{storeAddress},
"count": 10,
})
fmt.Println(string(out), err)
36. trace_rawTransaction —— 追踪一笔还没广播的交易
| 参数 | 类型 | 说明 |
|---|---|---|
rawTx | DATA | 已签名交易的 RLP 编码 |
traceTypes | 字符串数组 | 见上表 |
// signedTx:本地签好名的交易,组装方法见 3.10 的 eth_sendRawTransaction
raw, _ := signedTx.MarshalBinary() // 交易的二进制编码(RLP)
var out json.RawMessage
err := rpcClient.CallContext(ctx, &out, "trace_rawTransaction", hexutil.Bytes(raw), []string{"trace"})
fmt.Println(string(out), err) // 发送前先看看它会不会失败
37. trace_replayBlockTransactions —— 重放整块并取状态差异
| 参数 | 类型 | 说明 |
|---|---|---|
block | 区块号/标签 | 区块 |
traceTypes | 字符串数组 | 见上表 |
var out json.RawMessage
err := rpcClient.CallContext(ctx, &out, "trace_replayBlockTransactions",
hexutil.EncodeBig(blockNumber), []string{"stateDiff"})
fmt.Println(string(out), err)
38. trace_replayTransaction —— 重放单笔交易
| 参数 | 类型 | 说明 |
|---|---|---|
txHash | DATA, 32 字节 | 交易哈希 |
traceTypes | 字符串数组 | 见上表 |
var out json.RawMessage
err := rpcClient.CallContext(ctx, &out, "trace_replayTransaction", txHash, []string{"trace", "stateDiff"})
fmt.Println(string(out), err)
39. trace_transaction —— 单笔交易的所有内部调用
| 参数 | txHash:DATA, 32 字节 |
|---|
var out json.RawMessage
err := rpcClient.CallContext(ctx, &out, "trace_transaction", txHash)
fmt.Println(string(out), err)
3.9 erigon_ 扩展类(Erigon 独有,Geth 不支持)
这些是 Erigon 客户端为方便数据查询添加的扩展,部分服务商也提供(通常需要付费)。对 Geth 节点调用会返回
method not found。
40. erigon_blockNumber —— 最新区块号
| 参数 | 可选:block 标签(如 "latest"、"finalized") |
|---|---|
| 返回 | QUANTITY |
var n hexutil.Uint64
err := rpcClient.CallContext(ctx, &n, "erigon_blockNumber")
fmt.Println(uint64(n), err)
41 & 42. erigon_getHeaderByNumber / erigon_getHeaderByHash —— 只取区块头
| 方法 | 参数 |
|---|---|
erigon_getHeaderByNumber | blockNumber:QUANTITY |
erigon_getHeaderByHash | blockHash:32 字节 |
var h *types.Header
err := rpcClient.CallContext(ctx, &h, "erigon_getHeaderByNumber", hexutil.Uint64(5671744))
fmt.Println(h, err)
err = rpcClient.CallContext(ctx, &h, "erigon_getHeaderByHash", blockHash)
fmt.Println(h, err)
标准方法里对应的写法就是
client.HeaderByNumber/client.HeaderByHash。
43. erigon_getLogsByHash —— 一个区块里所有日志(按交易分组)
| 参数 | blockHash:32 字节 |
|---|---|
| 返回 | 二维数组:每笔交易一组日志 |
var logsPerTx [][]*types.Log
err := rpcClient.CallContext(ctx, &logsPerTx, "erigon_getLogsByHash", blockHash)
fmt.Println(len(logsPerTx), err)
44. erigon_getBlockByTimestamp —— “2024-04-11 那天的区块是哪个?”
| 参数 | 类型 | 说明 |
|---|---|---|
timestamp | 整数 | Unix 时间戳(秒) |
fullTx | Boolean | 是否返回完整交易 |
var block map[string]any
err := rpcClient.CallContext(ctx, &block, "erigon_getBlockByTimestamp", hexutil.Uint64(1712798400), false)
fmt.Println(block["number"], err) // 预期接近 5671744(该区块的时间戳正好是 1712798400)
用标准方法实现同样功能,需要自己对
HeaderByNumber做二分查找。
45. erigon_getLatestLogs —— 取“最新的 N 条”日志
| 参数 | 类型 | 说明 |
|---|---|---|
filter | 对象 | 同 eth_getLogs 的过滤条件 |
options | 对象 | logCount(返回多少条)、blockCount(搜索多少个区块)、ignoreTopicsOrder(是否忽略 topic 顺序) |
var logs []types.Log
err := rpcClient.CallContext(ctx, &logs, "erigon_getLatestLogs",
map[string]any{"address": storeAddress},
map[string]any{"logCount": 10})
fmt.Println(len(logs), err)
46. erigon_getBlockReceiptsByBlockHash —— 按区块哈希取全部收据
| 参数 | blockHash:32 字节 |
|---|---|
| 返回 | 收据数组 |
var receipts []*types.Receipt
err := rpcClient.CallContext(ctx, &receipts, "erigon_getBlockReceiptsByBlockHash", blockHash)
fmt.Println(len(receipts), err)
标准方法
eth_getBlockReceipts(client.BlockReceipts)已经能做同样的事,见下一节。
3.10 发送与签名类
47. eth_sendRawTransaction —— 所有“写操作”的唯一出口
通俗解释:把本地签好名的交易交给节点,节点验证后放入交易池,并通过 P2P 网络广播出去。返回交易哈希只代表“节点收下了”,执行成没成功还得看收据。
| 参数 | signedTx:DATA,已签名交易的二进制编码(Legacy 交易是 RLP,新类型交易是“类型字节 + RLP”) |
|---|---|
| 返回 | 交易哈希 |
// ETH 转账的完整流程(EIP-1559 交易)
nonce, _ := client.PendingNonceAt(ctx, fromAddress) // eth_getTransactionCount
tip, _ := client.SuggestGasTipCap(ctx) // eth_maxPriorityFeePerGas
head, _ := client.HeaderByNumber(ctx, nil) // eth_getBlockByNumber
chainID, _ := client.ChainID(ctx) // eth_chainId
to := common.HexToAddress("0x4592d8f8d7b001e72cb26a73e4fa1806a51ac79d")
tx := types.NewTx(&types.DynamicFeeTx{
ChainID: chainID,
Nonce: nonce,
GasTipCap: tip,
GasFeeCap: new(big.Int).Add(new(big.Int).Mul(head.BaseFee, big.NewInt(2)), tip),
Gas: 21000,
To: &to,
Value: big.NewInt(1e15), // 0.001 ETH
})
signedTx, _ := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey) // 本地签名,私钥不出门
if err := client.SendTransaction(ctx, signedTx); err != nil { // eth_sendRawTransaction
log.Fatal(err)
}
fmt.Println("tx sent:", signedTx.Hash().Hex())
一笔转账背后就用到了 5 个 JSON-RPC 方法。代币转账、部署合约、调用合约最终也都汇聚到
SendTransaction这一个出口;abigen 生成的部署、写方法,也是在内部完成了“查 nonce → 估 gas → 签名 → 发送”。这几个场景的核心代码见 3.15。privateKey、fromAddress的来历见 3.15.1。
48. eth_sendRawTransactionSync —— 发送并等待上链(Geth 扩展)
通俗解释:和 eth_sendRawTransaction 一样提交已签名交易,但节点会等交易被打包后直接返回收据,省掉自己轮询的步骤。这是 EIP-7966 提出的方法,Geth v1.17.8 已实现,但很多服务商暂时还不支持。
| 参数 | 类型 | 说明 |
|---|---|---|
signedTx | DATA | 已签名交易 |
timeout(可选) | QUANTITY | 最多等待多少毫秒,不填用节点默认值 |
| 返回 | 交易收据(超时则返回错误) |
|---|
timeout := 30 * time.Second
receipt, err := client.SendTransactionSync(ctx, signedTx, &timeout) // 底层:eth_sendRawTransactionSync
if err != nil {
log.Fatal(err)
}
fmt.Println("已上链,status =", receipt.Status)
49 ~ 51. eth_sendTransaction / eth_sign / eth_signTransaction —— 让节点替你签名
这三个方法的共同点:私钥保存在节点里,由节点替你签名。
| 方法 | 参数 | 返回 | 作用 |
|---|---|---|---|
eth_sendTransaction | 交易对象(from 必填,其余字段可省略,由节点补全) | 交易哈希 | 节点签名并直接发送 |
eth_sign | address、data | 65 字节签名 | 对消息签名,签名对象是 keccak256("\x19Ethereum Signed Message:\n" + 长度 + 消息)(EIP-191) |
eth_signTransaction | 交易对象(from、gas、nonce、费用字段都必填) | { raw, tx }:签好名的交易 | 只签名、不发送,之后可以用 eth_sendRawTransaction 发出 |
// 只在“节点托管了 from 账户私钥”时可用,例如 geth --dev 启动的开发链
var accounts []common.Address
_ = rpcClient.CallContext(ctx, &accounts, "eth_accounts")
from := accounts[0]
// ① 让节点签名并发送
var txHash common.Hash
err := rpcClient.CallContext(ctx, &txHash, "eth_sendTransaction", map[string]any{
"from": from,
"to": common.HexToAddress("0x4592d8f8d7b001e72cb26a73e4fa1806a51ac79d"),
"value": hexutil.EncodeBig(big.NewInt(1e15)),
})
fmt.Println(txHash.Hex(), err)
// ② 对一段消息签名
var sig hexutil.Bytes
err = rpcClient.CallContext(ctx, &sig, "eth_sign", from, hexutil.Bytes("hello"))
fmt.Println(sig, err)
// ③ 只签名不发送
var signed struct {
Raw hexutil.Bytes `json:"raw"`
Tx *types.Transaction `json:"tx"`
}
err = rpcClient.CallContext(ctx, &signed, "eth_signTransaction", map[string]any{
"from": from, "to": from, "value": "0x0", "gas": "0x5208", "gasPrice": "0x3b9aca00", "nonce": "0x0",
})
fmt.Println(signed.Tx.Hash().Hex(), err)
为什么实际开发中几乎不用它们?
- 公共 RPC 服务商不会替你保管私钥,调用会返回“账户不存在”之类的错误;
- Geth v1.14 起移除了
personal命名空间和--unlock参数,v1.17.8 里只有--dev开发模式(自动创建并解锁一个账户),或者通过--signer接入外部签名器 Clef 时才能使用;- 把私钥放在一台对外提供 RPC 的节点上,本身就是很大的安全风险。
所以正确做法永远是:本地签名 +
eth_sendRawTransaction。钱包里的“签名消息”(如登录 DApp),用的也是eth_sign同样的 EIP-191 格式,只是在钱包本地完成:crypto.Sign(accounts.TextHash(msg), privateKey)。
52. eth_fillTransaction —— 让节点帮你“填表”(Geth 扩展)
通俗解释:传入一笔不完整的交易,节点帮你补全 nonce、gas、费用等字段,返回未签名的交易,你再在本地签名发送。适合不想自己逐个调用 eth_getTransactionCount、eth_estimateGas、eth_maxPriorityFeePerGas 的场景。
| 参数 | 交易对象(至少要有 from) |
|---|---|
| 返回 | { raw, tx }:补全后的交易(未签名) |
var filled struct {
Raw hexutil.Bytes `json:"raw"`
Tx *types.Transaction `json:"tx"`
}
err := rpcClient.CallContext(ctx, &filled, "eth_fillTransaction", map[string]any{
"from": fromAddress,
"to": common.HexToAddress("0x4592d8f8d7b001e72cb26a73e4fa1806a51ac79d"),
"value": hexutil.EncodeBig(big.NewInt(1e15)),
})
if err != nil {
log.Fatal(err)
}
fmt.Println("nonce:", filled.Tx.Nonce(), "gas:", filled.Tx.Gas(), "maxFee:", filled.Tx.GasFeeCap())
chainID, _ := client.ChainID(ctx)
signedTx, _ := types.SignTx(filled.Tx, types.LatestSignerForChainID(chainID), privateKey) // 本地签名
_ = client.SendTransaction(ctx, signedTx)
3.11 过滤器与订阅类
监听链上新数据有两套机制,区别在于谁主动:
| 机制 | 方法 | 工作方式 | 连接要求 |
|---|---|---|---|
| 过滤器(轮询) | eth_newFilter 等 + eth_getFilterChanges | 先在节点上登记一个过滤器,之后客户端定期来问“有没有新数据” | HTTP 也可以 |
| 订阅(推送) | eth_subscribe | 节点有新数据就主动推送给客户端 | 只能 WebSocket / IPC |
过滤器保存在某一个节点的内存里:节点重启,或者负载均衡把你的下一次请求转到了另一台节点,过滤器 ID 就失效了(返回
filter not found)。另外,超过 5 分钟没有来取数据的过滤器会被自动删除,每调用一次eth_getFilterChanges就会重新计时。
53. eth_newFilter —— 登记一个日志过滤器
| 参数(一个过滤对象) | 类型 | 说明 |
|---|---|---|
fromBlock / toBlock | 区块号/标签 | 范围,默认都是 "latest" |
address | 地址或地址数组 | 只要这些合约的日志 |
topics | 数组 | 主题过滤,规则同 eth_getLogs |
| 返回 | 过滤器 ID(QUANTITY) |
|---|
var filterID string
err := rpcClient.CallContext(ctx, &filterID, "eth_newFilter", map[string]any{
"address": storeAddress,
"topics": []any{crypto.Keccak256Hash([]byte("ItemSet(bytes32,bytes32)"))}, // 只要 ItemSet 事件
})
fmt.Println("过滤器 ID:", filterID, err) // 例如 0x9a8f7b...
54. eth_newBlockFilter —— 登记一个“新区块”过滤器
| 参数 | 无 |
|---|---|
| 返回 | 过滤器 ID;之后用 eth_getFilterChanges 取到的是新区块哈希数组 |
var blockFilterID string
_ = rpcClient.CallContext(ctx, &blockFilterID, "eth_newBlockFilter")
55. eth_newPendingTransactionFilter —— 登记一个“新交易”过滤器
| 参数 | fullTx(可选,Geth 扩展):true 返回完整交易,默认只返回哈希 |
|---|---|
| 返回 | 过滤器 ID;之后取到的是进入交易池的新交易哈希数组 |
var txFilterID string
_ = rpcClient.CallContext(ctx, &txFilterID, "eth_newPendingTransactionFilter")
56. eth_getFilterChanges —— 取“上次以来的新数据”
通俗解释:轮询的核心方法。每次调用只返回自上次调用以来新增的数据,取走后就清空。
| 参数 | 过滤器 ID |
|---|---|
| 返回 | 日志过滤器 → 日志数组;新区块过滤器 → 区块哈希数组;新交易过滤器 → 交易哈希数组 |
// 用 HTTP 实现“监听合约事件”:每 12 秒(约一个出块间隔)问一次
ticker := time.NewTicker(12 * time.Second)
defer ticker.Stop()
for range ticker.C {
var logs []types.Log
if err := rpcClient.CallContext(ctx, &logs, "eth_getFilterChanges", filterID); err != nil {
log.Fatal(err) // filter not found:过滤器过期或节点重启,需要重新 eth_newFilter
}
for _, l := range logs {
fmt.Println("新事件:", l.BlockNumber, l.TxHash.Hex())
}
var newBlocks []common.Hash // 新区块过滤器返回的是哈希
_ = rpcClient.CallContext(ctx, &newBlocks, "eth_getFilterChanges", blockFilterID)
fmt.Println("新区块数:", len(newBlocks))
}
57. eth_getFilterLogs —— 取过滤器条件下的“全部”日志
通俗解释:和 eth_getFilterChanges 的区别是:它不管上次取到哪里,每次都按过滤器的条件返回全部匹配的日志,相当于“用登记好的条件执行一次 eth_getLogs”。只对 eth_newFilter 创建的日志过滤器有效。
| 参数 | 过滤器 ID |
|---|---|
| 返回 | 日志数组 |
var all []types.Log
err := rpcClient.CallContext(ctx, &all, "eth_getFilterLogs", filterID)
fmt.Println("匹配的日志总数:", len(all), err)
| 对比 | eth_getFilterChanges | eth_getFilterLogs |
|---|---|---|
| 返回内容 | 上次调用以来的增量 | 条件下的全量 |
| 会不会清空 | 会 | 不会 |
| 适用过滤器 | 三种都可以 | 只有日志过滤器 |
| 典型用途 | 持续监听 | 服务启动时补齐历史数据 |
58. eth_uninstallFilter —— 注销过滤器
| 参数 | 过滤器 ID |
|---|---|
| 返回 | Boolean:是否找到并删除了这个过滤器 |
var ok bool
_ = rpcClient.CallContext(ctx, &ok, "eth_uninstallFilter", filterID)
fmt.Println("已注销:", ok) // 不注销的话,5 分钟没人来取也会被自动删除
59 & 60. eth_subscribe / eth_unsubscribe —— 让节点主动“推送”
只能走 WebSocket 或 IPC。eth_subscribe 的第一个参数是订阅类型,返回订阅 ID;之后节点会不断推送 eth_subscription 通知;eth_unsubscribe(订阅ID) 取消订阅,返回 Boolean。
| 订阅类型 | 推送内容 | ethclient 方法 |
|---|---|---|
newHeads | 每个新区块头 | client.SubscribeNewHead |
logs | 符合过滤条件的新日志 | client.SubscribeFilterLogs |
newPendingTransactions | 进入交易池的新交易哈希 | geth.SubscribePendingTransactions |
transactionReceipts | 指定交易(或全部新交易)的收据(v1.17.8 新增) | client.SubscribeTransactionReceipts |
syncing | 节点同步状态变化 | rpcClient.EthSubscribe(ctx, ch, "syncing") |
wsClient, _ := ethclient.Dial("wss://eth-sepolia.g.alchemy.com/v2/<API_KEY>")
headers := make(chan *types.Header)
sub, err := wsClient.SubscribeNewHead(ctx, headers) // 底层:eth_subscribe("newHeads")
if err != nil {
log.Fatal(err)
}
defer sub.Unsubscribe() // 底层:eth_unsubscribe(订阅ID)
for {
select {
case err := <-sub.Err():
log.Fatal(err)
case h := <-headers:
fmt.Println("新区块:", h.Number, h.Hash().Hex())
}
}
实战场景:订阅合约事件(同样需要 WebSocket):
query := ethereum.FilterQuery{Addresses: []common.Address{storeAddress}}
logsCh := make(chan types.Log)
sub, err := wsClient.SubscribeFilterLogs(ctx, query, logsCh) // 底层:eth_subscribe("logs", query)
if err != nil {
log.Fatal(err)
}
for {
select {
case err := <-sub.Err():
log.Fatal(err)
case l := <-logsCh:
fmt.Println("新事件:", l.BlockNumber, l.TxHash.Hex(), l.Topics[0].Hex(), "removed:", l.Removed) // 解码方法同 3.3 的 eth_getLogs
}
}
注意:
- 处理推送的逻辑不要太慢。客户端订阅缓冲满了,订阅会被断开,重活应该丢给其他 goroutine;
- 发生链重组时,被撤销区块里的日志会以
Removed: true再推送一次,做业务时要处理;- 订阅在服务端是怎么实现的,见第四部分 4.6 和 4.9。
3.12 节点与客户端信息类
61. web3_clientVersion —— 节点软件版本
| 参数 | 无 |
|---|---|
| 返回 | 字符串,如 Geth/v1.17.8-stable/linux-amd64/go1.24.x |
var version string
_ = rpcClient.CallContext(ctx, &version, "web3_clientVersion")
fmt.Println(version) // 通过前缀能看出对方是 Geth、Erigon 还是 Nethermind
62. web3_sha3 —— 让节点帮你算 Keccak-256
| 参数 | DATA:要计算哈希的数据 |
|---|---|
| 返回 | 32 字节哈希 |
var h hexutil.Bytes
_ = rpcClient.CallContext(ctx, &h, "web3_sha3", hexutil.Bytes("transfer(address,uint256)"))
fmt.Println(h.String()[:10]) // 0xa9059cbb,就是 ERC-20 transfer 的函数选择器
// 本地计算结果完全相同,实际开发中直接用本地的就行,没必要发一次网络请求
fmt.Println(crypto.Keccak256Hash([]byte("transfer(address,uint256)")).Hex()[:10])
名字里的 “sha3” 是历史遗留:以太坊用的是 SHA-3 标准化之前的 Keccak-256,和最终的 SHA3-256 结果不同。
63. net_version —— 网络 ID
| 参数 | 无 |
|---|---|
| 返回 | 字符串形式的网络 ID,如 "11155111" |
networkID, _ := client.NetworkID(ctx)
fmt.Println(networkID) // 主网 1,Sepolia 11155111
网络 ID 用于 P2P 层识别“是不是同一个网络”,链 ID(
eth_chainId)用于交易签名。两者在主网和大多数测试网上数值相同,签名请始终用eth_chainId。
64. net_listening —— 节点是否在监听 P2P 连接
| 参数 | 无 |
|---|---|
| 返回 | Boolean |
var listening bool
_ = rpcClient.CallContext(ctx, &listening, "net_listening")
fmt.Println(listening) // Geth 中只要 P2P 服务在运行,就固定返回 true
65. net_peerCount —— 当前连接的对等节点数
| 参数 | 无 |
|---|---|
| 返回 | QUANTITY |
peers, _ := client.PeerCount(ctx)
fmt.Println("peers:", peers) // 自建节点长期为 0,说明网络有问题,数据很可能不是最新的
66. eth_config —— 当前与下一个硬分叉的配置(EIP-7910)
通俗解释:一次性返回节点当前生效的硬分叉配置,以及下一个、最后一个已排期分叉的配置:激活时间、链 ID、ForkID、blob 参数、预编译合约地址、系统合约地址。适合用来检查“我的节点是否已经为下一次升级做好准备”。
| 参数 | 无 |
|---|---|
| 返回 | { current, next, last },每项包含 activationTime、blobSchedule、chainId、forkId、precompiles、systemContracts |
var cfg map[string]json.RawMessage
err := rpcClient.CallContext(ctx, &cfg, "eth_config")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(cfg["current"])) // {"activationTime":..., "chainId":"0xaa36a7", "forkId":"0x...", ...}
fmt.Println(string(cfg["next"])) // 没有已排期的下一个分叉时为 null
67. eth_capabilities —— 节点能提供哪些历史数据
通俗解释:随着“历史数据过期”和状态修剪,不同节点保存的历史范围差别很大。这个方法告诉你:这个节点的状态、交易索引、日志索引、收据、区块分别能查到多早,以及按什么策略删除。RPC 路由服务可以据此把请求转发给“查得到”的节点。
| 参数 | 无 |
|---|---|
| 返回 | head(链头),以及 state、tx、logs、receipts、blocks、stateproofs 各自的 disabled、oldestBlock、deleteStrategy |
var caps struct {
Head struct {
Number hexutil.Uint64 `json:"number"`
} `json:"head"`
State struct {
OldestBlock *hexutil.Uint64 `json:"oldestBlock"`
} `json:"state"`
Logs struct {
Disabled bool `json:"disabled"`
OldestBlock *hexutil.Uint64 `json:"oldestBlock"`
} `json:"logs"`
}
err := rpcClient.CallContext(ctx, &caps, "eth_capabilities")
fmt.Println(uint64(caps.Head.Number), caps.State.OldestBlock, caps.Logs.OldestBlock, err)
这是新近加入规范的方法(execution-apis #755),目前支持的节点和服务商还不多。
3.13 状态与执行增强类
68. eth_getBlockReceipts —— 一次拿整块的收据
| 参数 | block:区块号/标签,或区块哈希 |
|---|---|
| 返回 | 该区块所有交易的收据数组 |
// 参数可以是区块号,也可以是区块哈希
byHash, _ := client.BlockReceipts(ctx, rpc.BlockNumberOrHashWithHash(blockHash, false))
byNum, _ := client.BlockReceipts(ctx, rpc.BlockNumberOrHashWithNumber(rpc.BlockNumber(blockNumber.Int64())))
fmt.Println(len(byHash), len(byNum), byHash[0].TxHash == byNum[0].TxHash) // 70 70 true
常见误区:比较两次查询的结果时,不要写
byHash[0] == byNum[0]。它比较的是两个指针,两次请求解码出来的是两个不同对象,结果永远是false。要比较内容,就比较TxHash等字段。做区块数据入库时,一个区块调用一次
eth_getBlockReceipts,比逐笔调用eth_getTransactionReceipt少得多的请求次数。
69. eth_getStorageValues —— 批量读多个合约的多个存储槽
通俗解释:eth_getStorageAt 的批量版:一次请求读取多个合约、多个槽位的值。Geth 限制单次最多 1024 个槽位。
| 参数 | 类型 | 说明 |
|---|---|---|
requests | 对象 | { 合约地址: [槽位1, 槽位2, ...], ... } |
block(可选) | 区块号/标签 | 默认 "latest" |
| 返回 | { 合约地址: [值1, 值2, ...] },顺序和请求一致 |
|---|
var values map[common.Address][]hexutil.Bytes
err := rpcClient.CallContext(ctx, &values, "eth_getStorageValues",
map[common.Address][]common.Hash{
storeAddress: {common.Hash{}, common.BigToHash(big.NewInt(1))}, // 槽 0、槽 1
}, "latest")
fmt.Printf("%x %v\n", values[storeAddress][0], err)
70. eth_baseFee —— 下一个区块的基础费
| 参数 | 无 |
|---|---|
| 返回 | QUANTITY,下一个区块的 baseFee(wei) |
var baseFee hexutil.Big
_ = rpcClient.CallContext(ctx, &baseFee, "eth_baseFee")
fmt.Println("下一块 baseFee:", baseFee.ToInt())
以前要用
HeaderByNumber取最新区块的BaseFee(那是当前块的),再自己按 EIP-1559 公式推算下一块。eth_baseFee直接给出下一块的值,组装 EIP-1559 交易时更准确。
71. eth_createAccessList —— 提前声明要访问的存储(EIP-2930)
通俗解释:让节点模拟执行一次交易,返回它会访问的地址和存储槽列表。把这个列表放进交易,被访问的数据会被预先标记为“热”数据,访问时 gas 更便宜,复杂交易能省一些 gas。
| 参数 | 类型 | 说明 |
|---|---|---|
transaction | 对象 | 同 eth_call |
block(可选) | 区块号/标签 | 默认 "latest" |
| 返回 | { accessList, gasUsed, error } |
|---|
// 返回:访问列表、使用访问列表后的 gas、执行错误信息
al, gasUsed, vmErr, err := geth.CreateAccessList(ctx, ethereum.CallMsg{From: fromAddress, To: &storeAddress, Data: data})
fmt.Println(al, gasUsed, vmErr, err)
72. eth_simulateV1 —— 一次模拟多个区块、多笔调用
通俗解释:eth_call 的“超级加强版”:可以在模拟中连续执行多笔调用(后一笔能看到前一笔的结果),跨越多个模拟区块,还能临时修改账户余额、代码、存储和区块参数。适合模拟“先 approve 再 swap”这类多步操作。Geth 限制最多模拟 256 个区块。
| 参数 | 类型 | 说明 |
|---|---|---|
opts.blockStateCalls | 数组 | 每项是一个模拟区块:blockOverrides、stateOverrides、calls |
opts.validation | Boolean | 是否像真实交易一样校验 nonce、余额、费用 |
opts.traceTransfers | Boolean | 是否把 ETH 转账也记录成日志 |
block | 区块号/标签 | 基于哪个区块的状态 |
| 返回 | 每个模拟区块的结果:区块号、gasUsed,以及每笔调用的 returnData、logs、gasUsed、status、error |
|---|
latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)
results, err := client.SimulateV1(ctx, ethclient.SimulateOptions{
BlockStateCalls: []ethclient.SimulateBlock{{
Calls: []ethereum.CallMsg{
{From: fromAddress, To: &storeAddress, Data: setItemData}, // 第 1 笔:写入
{From: fromAddress, To: &storeAddress, Data: itemsData}, // 第 2 笔:读出,能看到第 1 笔的结果
},
}},
}, &latest)
if err != nil {
log.Fatal(err)
}
for _, c := range results[0].Calls {
fmt.Printf("status=%d gas=%d ret=%x\n", c.Status, c.GasUsed, c.ReturnValue)
}
setItemData、itemsData可以用 3.15.5 中的storeABI.Pack("setItem", key, value)、storeABI.Pack("items", key)生成。
73. eth_getBlockAccessList —— 区块访问列表(⚠️ Geth v1.17.8 未实现)
通俗解释:返回一个区块执行过程中访问、修改过的所有账户和存储槽(EIP-7928,Block-level Access List,简称 BAL),可以用来并行执行区块、加快同步。规范里已经定义,但 Geth v1.17.8 还没有提供这个 RPC 方法(内部已经在为 Amsterdam 分叉处理 BAL 数据),调用会返回 -32601。
| 参数 | 区块号/标签,或区块哈希 |
|---|---|
| 返回 | 访问列表对象 |
var bal json.RawMessage
err := rpcClient.CallContext(ctx, &bal, "eth_getBlockAccessList", "latest")
fmt.Println(string(bal), err) // Geth v1.17.8:-32601
3.14 debug* 原始数据与 txpool* 交易池类
74 ~ 77. debug_getRawHeader / debug_getRawBlock / debug_getRawTransaction / debug_getRawReceipts —— 取 RLP 原始字节
普通方法返回的是 JSON,这组方法返回的是数据在链上的原始二进制编码(RLP),适合自己做校验、计算哈希、做数据归档。
| 方法 | 参数 | 返回 |
|---|---|---|
debug_getRawHeader | 区块号/标签或哈希 | 区块头的 RLP |
debug_getRawBlock | 区块号/标签或哈希 | 整个区块的 RLP |
debug_getRawTransaction | 交易哈希 | 交易的二进制编码(和 eth_sendRawTransaction 提交时的格式相同) |
debug_getRawReceipts | 区块号/标签或哈希 | 该区块每个收据的编码数组 |
var rawHeader hexutil.Bytes
_ = rpcClient.CallContext(ctx, &rawHeader, "debug_getRawHeader", hexutil.EncodeBig(blockNumber))
var header types.Header
_ = rlp.DecodeBytes(rawHeader, &header) // 包:github.com/ethereum/go-ethereum/rlp
fmt.Println(header.Hash() == blockHash) // true:区块哈希就是区块头 RLP 的 Keccak256
var rawTx hexutil.Bytes
_ = rpcClient.CallContext(ctx, &rawTx, "debug_getRawTransaction", txHash)
var tx types.Transaction
_ = tx.UnmarshalBinary(rawTx)
fmt.Println(tx.Hash() == txHash) // true
var rawReceipts []hexutil.Bytes
_ = rpcClient.CallContext(ctx, &rawReceipts, "debug_getRawReceipts", hexutil.EncodeBig(blockNumber))
fmt.Println("收据数:", len(rawReceipts)) // 70
这组方法属于
debug命名空间,需要节点开放debug;数据格式的细节见第四部分 4.3(RLP)。
78. debug_getBadBlocks —— 节点最近拒收的“坏区块”
| 参数 | 无 |
|---|---|
| 返回 | 数组:每项包含区块哈希、区块 JSON、RLP 和拒收原因 |
var bad []map[string]any
_ = rpcClient.CallContext(ctx, &bad, "debug_getBadBlocks")
fmt.Println("坏区块数:", len(bad)) // 正常为 0;客户端出现共识分歧时,这里是排查的第一现场
79. debug_getRawBlockAccessList —— 区块访问列表的原始编码(⚠️ Geth v1.17.8 未实现)
规范中与 eth_getBlockAccessList 配套的原始数据版本,Geth v1.17.8 同样尚未提供,调用返回 -32601。
80 ~ 83. txpool_status / txpool_content / txpool_contentFrom / txpool_inspect —— 查看交易池
| 方法 | 参数 | 返回 |
|---|---|---|
txpool_status | 无 | { pending, queued }:两类交易的数量 |
txpool_content | 无 | 全部交易,按 pending/queued → 发送地址 → nonce 分组 |
txpool_contentFrom | address | 只返回某个地址的交易 |
txpool_inspect | 无 | 和 content 结构相同,但每笔交易压缩成一行摘要(Geth 扩展) |
- pending:nonce 连续、马上可以被打包的交易;
- queued:nonce 不连续(前面缺了一笔)、暂时不能打包的交易。
var status map[string]hexutil.Uint
_ = rpcClient.CallContext(ctx, &status, "txpool_status")
fmt.Println("pending:", status["pending"], "queued:", status["queued"])
// 我的交易为什么一直不上链?看看它卡在 pending 还是 queued
var mine map[string]map[string]map[string]any // pending/queued → nonce → 交易
_ = rpcClient.CallContext(ctx, &mine, "txpool_contentFrom", fromAddress)
for nonce := range mine["queued"] {
fmt.Println("nonce", nonce, "在 queued:前面有更小的 nonce 还没发出或还没上链")
}
var summary map[string]map[string]map[string]string
_ = rpcClient.CallContext(ctx, &summary, "txpool_inspect")
// 每笔交易一行,例如 "0x4592...: 1000000000000000 wei + 21000 gas × 30000000000 wei"
txpool_需要节点开放txpool命名空间;txpool_content在交易池很满时返回的数据量很大,生产环境尽量用txpool_contentFrom只查自己关心的地址。交易池的内部机制见第四部分 4.8。
3.15 实战:常见写操作的核心代码
前面按方法逐个讲完了,这一节把它们组合起来,给出开发中最常见的几个“写操作”场景。所有场景都遵循同一个套路:

不同场景的区别只在于交易的 to 和 data 怎么填:
| 场景 | to | value | data |
|---|---|---|---|
| ETH 转账 | 收款人地址 | 转账金额 | 空 |
| 代币转账 | 代币合约地址 | 0 | transfer(收款人, 数量) 的 ABI 编码 |
| 部署合约 | 空(nil) | 通常为 0 | 合约字节码 + 构造参数的 ABI 编码 |
| 调用合约 | 合约地址 | 通常为 0 | 函数选择器 + 参数的 ABI 编码 |
3.15.1 准备钱包:私钥 → 公钥 → 地址
import (
"crypto/ecdsa"
"github.com/ethereum/go-ethereum/crypto"
)
// 新建一个钱包(随机私钥)
privateKey, _ := crypto.GenerateKey()
fmt.Println("私钥:", hexutil.Encode(crypto.FromECDSA(privateKey))[2:]) // 去掉 0x,千万不要泄露
// 或者从已有私钥恢复
privateKey, err := crypto.HexToECDSA("<你的私钥十六进制,不带 0x>")
if err != nil {
log.Fatal(err)
}
// 私钥 → 公钥 → 地址(地址 = 公钥 Keccak256 哈希的后 20 字节)
publicKey := privateKey.Public().(*ecdsa.PublicKey)
fromAddress := crypto.PubkeyToAddress(*publicKey)
fmt.Println("地址:", fromAddress.Hex())
本章中出现的
privateKey、fromAddress都来自这里。如果手上是钱包导出的 keystore 文件,可以用keystore.DecryptKey(keyJSON, 密码)解出私钥(包github.com/ethereum/go-ethereum/accounts/keystore)。安全提示:示例为了直观把私钥写在代码里,真实项目应该从环境变量、加密配置或签名服务中读取,绝不能提交到代码仓库。
3.15.2 ETH 转账
完整代码见 3.10 的 eth_sendRawTransaction。如果节点或链不支持 EIP-1559,可以用 Legacy 交易:
nonce, _ := client.PendingNonceAt(ctx, fromAddress)
gasPrice, _ := client.SuggestGasPrice(ctx)
chainID, _ := client.ChainID(ctx)
to := common.HexToAddress("0x4592d8f8d7b001e72cb26a73e4fa1806a51ac79d")
tx := types.NewTransaction(nonce, to, big.NewInt(1e18), 21000, gasPrice, nil) // 转 1 ETH
signedTx, _ := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
if err := client.SendTransaction(ctx, signedTx); err != nil {
log.Fatal(err)
}
3.15.3 代币(ERC-20)转账
代币转账不是把 ETH 转给收款人,而是“调用代币合约的 transfer 函数”,所以 to 是代币合约地址、value 是 0、收款人写在 data 里:
tokenAddress := common.HexToAddress("<ERC-20 合约地址>")
toAddress := common.HexToAddress("0x4592d8f8d7b001e72cb26a73e4fa1806a51ac79d")
amount, _ := new(big.Int).SetString("1000000000000000000000", 10) // 1000 个代币(18 位小数)
// ① 拼 data:transfer(address,uint256)
methodID := crypto.Keccak256([]byte("transfer(address,uint256)"))[:4] // 0xa9059cbb
data := append(methodID, common.LeftPadBytes(toAddress.Bytes(), 32)...)
data = append(data, common.LeftPadBytes(amount.Bytes(), 32)...)
// ② 估 gas:To 填代币合约地址
gasLimit, err := client.EstimateGas(ctx, ethereum.CallMsg{From: fromAddress, To: &tokenAddress, Data: data})
if err != nil {
log.Fatal(err) // 余额不足等情况会在这里提前报错
}
// ③ 组装、签名、发送
nonce, _ := client.PendingNonceAt(ctx, fromAddress)
gasPrice, _ := client.SuggestGasPrice(ctx)
chainID, _ := client.ChainID(ctx)
tx := types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit*12/10, gasPrice, data) // gas 多留 20%
signedTx, _ := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
if err := client.SendTransaction(ctx, signedTx); err != nil {
log.Fatal(err)
}
fmt.Println("tx sent:", signedTx.Hash().Hex())
手动拼
data是为了看清原理。实际开发中更常用 ABI 自动编码:erc20ABI.Pack("transfer", toAddress, amount),效果完全一样。
3.15.4 部署合约
部署合约本质上也是一笔交易:to 为空,data 是合约字节码。先用 solc 编译出字节码(.bin)和 ABI(.abi):
npm install -g solc
solcjs --bin --abi Store.sol
方式一:用 abigen 生成 Go 代码后部署(推荐)
go install github.com/ethereum/go-ethereum/cmd/abigen@v1.17.8
abigen --v2 --bin=Store_sol_Store.bin --abi=Store_sol_Store.abi --type=Store --pkg=store --out=store.go
import bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2"
chainID, _ := client.ChainID(ctx)
auth := bind.NewKeyedTransactor(privateKey, chainID) // 交易签名者
// nonce、gas 价格、gasLimit 不填时,bind 会自动调用对应的 JSON-RPC 方法获取
storeBinding := store.NewStore()
address, tx, err := bind.DeployContract(
auth,
common.FromHex(store.StoreMetaData.Bin), // 生成代码里自带的字节码
client,
storeBinding.PackConstructor("1.0"), // 构造函数参数 _version 的 ABI 编码
)
if err != nil {
log.Fatal(err)
}
fmt.Println("合约地址:", address.Hex(), "部署交易:", tx.Hash().Hex())
receipt, _ := bind.WaitMined(ctx, client, tx.Hash()) // 等待部署交易上链
fmt.Println("部署结果:", receipt.Status)
方式二:只用 ethclient,直接发送字节码
bytecode, _ := hex.DecodeString("<Store_sol_Store.bin 的内容>")
storeABI, _ := abi.JSON(strings.NewReader(StoreABI)) // StoreABI:Store_sol_Store.abi 的内容
ctorArgs, _ := storeABI.Pack("", "1.0") // 方法名传空字符串 = 编码构造函数参数
data := append(bytecode, ctorArgs...) // 字节码 + 构造参数
nonce, _ := client.PendingNonceAt(ctx, fromAddress)
gasPrice, _ := client.SuggestGasPrice(ctx)
chainID, _ := client.ChainID(ctx)
tx := types.NewContractCreation(nonce, big.NewInt(0), 3000000, gasPrice, data) // 没有 to 参数
signedTx, _ := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
if err := client.SendTransaction(ctx, signedTx); err != nil {
log.Fatal(err)
}
receipt, _ := bind.WaitMined(ctx, client, signedTx.Hash())
fmt.Println("合约地址:", receipt.ContractAddress.Hex())
常见误区:
Store的构造函数有一个参数,必须把它的 ABI 编码拼在字节码后面,否则部署会失败或得到错误的状态。合约地址只由“部署者地址 + nonce”决定,所以发送前就能用crypto.CreateAddress(fromAddress, nonce)算出来。
3.15.5 加载并调用合约
方式一:abigen 生成的代码
如果只有 ABI(比如调用别人的合约),用 abigen --v2 --abi=Store_sol_Store.abi --type=Store --pkg=store --out=store.go 生成代码即可。
storeBinding := store.NewStore()
instance := storeBinding.Instance(client, storeAddress) // 加载:只需要地址 + 客户端,不发任何交易
var key, value [32]byte
copy(key[:], []byte("demo_save_key"))
copy(value[:], []byte("demo_save_value"))
// 写:会修改状态 → 签名并发送交易(底层 eth_sendRawTransaction)
chainID, _ := client.ChainID(ctx)
auth := bind.NewKeyedTransactor(privateKey, chainID)
tx, err := bind.Transact(instance, auth, storeBinding.PackSetItem(key, value))
if err != nil {
log.Fatal(err)
}
bind.WaitMined(ctx, client, tx.Hash()) // 等上链后再读,否则读到的还是旧值
// 读:view 函数 → 不发交易(底层 eth_call)
got, _ := bind.Call(instance, &bind.CallOpts{Context: ctx}, storeBinding.PackItems(key), storeBinding.UnpackItems)
fmt.Println("写入成功?", got == value)
方式二:只用 ABI,自己打包 calldata
storeABI, _ := abi.JSON(strings.NewReader(StoreABI))
// 写:Pack 生成 “函数选择器 + 参数” 的 data,然后按 3.15.2 的套路签名发送
input, _ := storeABI.Pack("setItem", key, value)
nonce, _ := client.PendingNonceAt(ctx, fromAddress)
gasPrice, _ := client.SuggestGasPrice(ctx)
tx := types.NewTransaction(nonce, storeAddress, big.NewInt(0), 300000, gasPrice, input)
signedTx, _ := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
_ = client.SendTransaction(ctx, signedTx)
bind.WaitMined(ctx, client, signedTx.Hash())
// 读:eth_call 后用 Unpack 解出返回值
callData, _ := storeABI.Pack("items", key)
out, _ := client.CallContract(ctx, ethereum.CallMsg{To: &storeAddress, Data: callData}, nil)
var stored [32]byte
_ = storeABI.UnpackIntoInterface(&stored, "items", out)
fmt.Println("写入成功?", stored == value)
不用 ABI 也可以:
setItem(bytes32,bytes32)的 data 就是crypto.Keccak256([]byte("setItem(bytes32,bytes32)"))[:4]后面直接接key[:]和value[:],因为bytes32本身就是 32 字节,不需要补齐。复杂类型(string、数组、结构体)的编码规则比较繁琐,还是交给abi.Pack最稳妥。
3.16 小结:一张图串起常见开发场景

第四部分:Go-Ethereum 源码解析
版本说明:本部分以 go-ethereum v1.17.8 源码为准,文中出现的结构体、常量和代码片段都摘自该版本(为了篇幅,部分代码做了节选或删去了注释)。想跟着读源码,可以直接打开 Go 模块缓存中的
$(go env GOMODCACHE)/github.com/ethereum/go-ethereum@v1.17.8,或者在 GitHub 上切到v1.17.8标签。源码里已经包含了一些尚未在主网激活的未来分叉代码(如 Amsterdam 的 EIP-8037 双维 gas、区块访问列表 BAL 等)。下文提到它们时会特别注明。
4.0 先看全景:一个 Geth 节点里都有什么
前三部分我们一直站在节点“门外”,通过 JSON-RPC 和它对话。现在推门进去。

上图根据以太坊黄皮书绘制,是理解以太坊“状态机”最经典的一张图:中间是 EVM 的执行循环(指令集、执行环境、机器状态),右边是区块头字段,以及它指向的世界状态树、账户存储树、交易树、收据树。图里左侧的“矿工”流程诞生于 PoW 时代,合并之后由共识层客户端负责出块,但“执行交易 → 更新状态 → 算出各种根哈希”这条主线完全没变。
把 Geth 节点想象成一座城市,各个模块各司其职:
| 城市角色 | Geth 模块 | 一句话职责 | 本文小节 |
|---|---|---|---|
| 统一的打包规范 | rlp | 把任意数据打包成唯一确定的字节 | 4.3 |
| 带指纹的档案柜 | trie / triedb / core/state | 存账户和合约存储,给整柜算出一个“总指纹”(状态根) | 4.4 |
| 仓库 | ethdb / core/rawdb | 键值数据库 + 冷数据档案库,真正把字节写到磁盘 | 4.5 |
| 市民服务大厅 | rpc / node | JSON-RPC 服务端与客户端,连接外部世界的大门 | 4.6 |
| 邮政与交通网 | p2p | 找到其他节点、建立加密通信 | 4.7 |
| 物流调度中心 | eth(handler / fetcher / downloader / catalyst)、core/txpool | 交易池、交易广播、同步、配合共识层出块 | 4.8 |
| 档案检索台 | eth/filters、core/filtermaps | 快速查日志、支持订阅 | 4.9 |
| 计算工厂 | core/vm | EVM:执行合约字节码、按 gas 计费 | 4.10 |
我们按“底层数据 → 对外接口 → 网络与同步 → 执行”的顺序往上走,最后用一笔交易的完整旅程把它们串起来(4.11)。
4.1 准备工作台:源码环境与目录地图
拉代码、跑测试
git clone --branch v1.17.8 --depth 1 https://github.com/ethereum/go-ethereum.git
cd go-ethereum && go test ./rlp/...
测试通过,就可以边读边调试了。用 GoLand 或 VS Code(安装 Go 插件)打开目录,借助“跳转到定义”和“查找引用”读源码效率最高。
小坑:
crypto/secp256k1依赖 cgo,Windows 上如果报exec: "gcc": executable file not found,需要先安装 GCC(如 MinGW-w64)。
目录地图(v1.17.8)
| 目录 | 作用 | 和本文的关系 |
|---|---|---|
accounts | 账户管理、keystore、ABI 编解码(accounts/abi)、abigen 与合约绑定运行时(accounts/abi/bind/v2) | 第三部分的 abi.JSON、bind.Call、bind.DeployContract |
beacon | 与信标链(共识层)相关的轻客户端、类型定义 | 合并后才有 |
cmd | 命令行工具:geth、abigen、bootnode、evm、rlpdump、ethkey、devp2p、clef 等 | 你启动的节点、生成合约绑定的工具都在这 |
common | 公共工具:Address、Hash、hexutil | 几乎每个示例都在用 |
consensus | 共识引擎:beacon(合并后)、clique(PoA)、ethash(只保留验证老区块所需的部分) | — |
core | 核心:区块链(blockchain.go)、交易池(txpool)、状态(state)、EVM(vm)、类型(types)、底层存储规则(rawdb)、日志索引(filtermaps) | 4.4、4.5、4.8~4.10 |
crypto | 哈希与签名:Keccak256、secp256k1、KZG | 第三部分创建钱包、签名交易 |
eth | 以太坊协议与服务:网络 handler、同步(downloader)、Engine API(catalyst)、过滤器(filters)、追踪器(tracers)、gas 估算与价格预言机 | 4.8、4.9 |
ethclient | JSON-RPC 客户端(含 gethclient) | 第三部分全部示例 |
ethdb | 数据库抽象:pebble / leveldb / memorydb | 4.5 |
event | 进程内事件订阅(Feed) | 订阅推送的底层 |
graphql | GraphQL 查询接口 | — |
internal/ethapi | eth_ 命名空间大部分方法的服务端实现 | 第二部分 2.3、4.6 |
miner | 组装区块(合并后不再挖矿) | 4.8 |
node | 节点容器:组装服务、启动 HTTP / WS / IPC / authrpc、打开数据库 | 4.6 |
p2p | 点对点网络:节点发现、RLPx、拨号 | 4.7 |
params | 链配置、硬分叉时间、gas 常量 | — |
rlp | 序列化 | 4.3 |
rpc | JSON-RPC 框架 | 4.6 |
signer | 独立签名器 Clef 的实现 | — |
trie / triedb | Merkle Patricia Trie 及其存储层(hashdb / pathdb) | 4.4 |
阅读建议:先读相对独立的模块(rlp → trie → ethdb),再读依赖多的(rpc、p2p、eth、core)。本文也是按这个顺序安排的。
4.2 以太坊是一台状态机
读源码时经常碰到注释里的希腊字母,它们来自以太坊黄皮书。最核心的只有一行:
$$ \sigma_{t+1} \equiv \Upsilon(\sigma_t, T) $$
翻译成人话:旧的世界状态 σ,执行一笔交易 T,得到新的世界状态。 整个以太坊就是一台不停执行这个函数的“状态机”,区块只是把一批交易打包,一起推进状态。
| 符号 | 含义 | 对应到 Go 代码 / JSON-RPC |
|---|---|---|
| σ | 世界状态(所有账户) | state.StateDB;eth_getBalance 读的就是它 |
| Υ | 交易级状态转换函数 | core.ApplyMessage → stateTransition.execute → EVM |
| 账户 (n, b, s, c) | nonce、balance、storageRoot、codeHash | types.StateAccount;eth_getProof 返回的四个字段 |
| T_n / T_p / T_g / T_t / T_v / T_d | 交易的 nonce、gasPrice、gasLimit、to、value、data | types.NewTransaction(nonce, to, value, gasLimit, gasPrice, data) 的参数 |
| T_w, T_r, T_s | 签名 (v, r, s),能恢复出发送方 | types.Sender(signer, tx) |
| H_r / H_t / H_e / H_b | 区块头里的 stateRoot、txRoot、receiptRoot、logsBloom | header.Root、header.TxHash、header.ReceiptHash、header.Bloom |
| R_u / R_l / R_b | 收据的累计 gasUsed、日志、bloom | receipt.CumulativeGasUsed、receipt.Logs、receipt.Bloom |
| μ = (g, pc, m, i, s) | EVM 机器状态:剩余 gas、程序计数器、内存、内存字数、栈 | 4.10 的 Contract.Gas、pc、Memory、Stack |
记住这张表,后面看到 stateRoot、receiptRoot、bloom 就知道它们在全景图的哪个位置。
4.3 RLP:以太坊的“打包规范”
它解决什么问题
节点之间要传区块、交易;数据库要存树节点;计算哈希前要把对象变成字节。同一个对象,必须在所有节点上编码出完全相同的字节,否则哈希就对不上。RLP(Recursive Length Prefix,递归长度前缀)就是以太坊为此设计的极简序列化格式。
类比:寄快递。 每个包裹外面先贴一张标签:“这是一件东西 / 这是一箱东西,里面有多长”。大箱子里还能套小箱子,拆包的人只看标签就知道怎么拆。
规则:只有两种东西
RLP 眼里的世界只有两种数据:字节串(string)和列表(list,元素可以是字节串,也可以是列表)。Go 里的结构体被当成“按字段顺序排列的列表”。整数先转成去掉前导零的大端字节,再当字节串编码。
看第一个字节就能知道后面是什么:
| 首字节范围 | 含义 | 规则 |
|---|---|---|
0x00–0x7f | 单个字节本身 | 只有 1 个字节且 < 128,原样输出 |
0x80–0xb7 | 短字符串(0–55 字节) | 前缀 = 0x80 + 长度 |
0xb8–0xbf | 长字符串(≥ 56 字节) | 前缀 = 0xb7 + 长度的字节数,后面跟长度本身 |
0xc0–0xf7 | 短列表(内容总长 0–55) | 前缀 = 0xc0 + 内容总长 |
0xf8–0xff | 长列表 | 前缀 = 0xf7 + 长度的字节数,后面跟长度本身 |
动手算几个例子:
| 原始数据 | RLP 编码 | 解释 |
|---|---|---|
"dog" | 83 64 6f 67 | 0x80+3=0x83,后面是 d、o、g |
["cat", "dog"] | c8 83 63 61 74 83 64 6f 67 | 两个元素各 4 字节,共 8 字节 → 0xc0+8=0xc8 |
""(空串) | 80 | 源码常量 EmptyString = 0x80 |
[](空列表) | c0 | 源码常量 EmptyList = 0xC0 |
整数 0 | 80 | 0 的“去前导零大端表示”是空串 |
整数 15 | 0f | 单字节且 < 128,原样 |
整数 1024 | 82 04 00 | 0x0400 两个字节 |
| 56 字节的字符串 | b8 38 … | 长度 56=0x38 只需 1 字节表示 → 0xb7+1=0xb8 |
规范性很重要:
0x81 0x05和0x05看起来都能“理解”成 5,但 RLP 只承认后者。解码器在三个地方做规范性检查:单字节却加了前缀、本该用短格式却用了长格式、长度字段带前导零,违反就返回ErrCanonSize/ErrCanonInt。原因很简单:一个值只能有一种编码,哈希才唯一。
源码怎么实现:四个巧思
rlp 包的核心文件:encode.go / encbuffer.go(编码)、decode.go(解码)、typecache.go(类型缓存)、raw.go(未解码的原始 RLP),以及代码生成工具 rlpgen/。
巧思 1:用反射“按类型派活”,并且读缓存不加锁(typecache.go)
rlp.Encode(w, val) 拿到的是 any,RLP 包用反射查看类型,为每种类型生成一对“编码器 / 解码器”并缓存起来:
// rlp/typecache.go
type typeCache struct {
cur atomic.Value
// This lock synchronizes writers.
mu sync.Mutex
next map[typekey]*typeinfo
}
- 读:
cur.Load()拿到当前 map 直接查,完全不加锁。编解码是超高频操作,这一点很关键; - 写(遇到新类型):加锁,复制一份 map(写时复制),生成新类型的编解码器后再整体替换
cur; - 缓存的 key 是“类型 + struct tag”:同一个类型带不同的 tag,生成的编解码器也可能不同;
- 生成之前先放一个占位,这样遇到“结构体里引用自己类型”的递归定义时,不会无限递归。
巧思 2:“先占位,后补头”(encbuffer.go)
编码列表时有个鸡生蛋的问题:列表头里要写内容总长度,但写头的时候内容还没编码出来。 解决办法:
// rlp/encbuffer.go
type encBuffer struct {
str []byte // string data, contains everything except list headers
lheads []listhead // all list headers
lhsize int // sum of sizes of all encoded list headers
sizebuf [9]byte // auxiliary buffer for uint encoding
}
func (buf *encBuffer) list() int {
buf.lheads = append(buf.lheads, listhead{offset: len(buf.str), size: buf.lhsize})
return len(buf.lheads) - 1
}
func (buf *encBuffer) listEnd(index int) {
lh := &buf.lheads[index]
lh.size = buf.size() - lh.offset - lh.size
if lh.size < 56 {
buf.lhsize++ // length encoded into kind tag
} else {
buf.lhsize += 1 + intsize(uint64(lh.size))
}
}

- 开始写列表时,
list()只记下“这个列表从str的第几个字节开始”; - 写完所有元素后,
listEnd()算出内容长度,进而知道这个列表头需要占几个字节,累加到lhsize; - 最后
makeBytes()按len(str) + lhsize一次性分配好内存,copyTo()把数据片段和列表头交错拷贝进去。
就像先把所有箱子装好、量好尺寸,最后统一贴标签:只分配一次内存,也不用回头挪动已经写好的数据。encBuffer 本身还通过 sync.Pool 复用,进一步减少内存分配。
巧思 3:用“栈”解码嵌套列表(decode.go 的 Stream)
// rlp/decode.go
type Stream struct {
r ByteReader
remaining uint64 // number of bytes remaining to be read from r
size uint64 // size of value ahead
kinderr error // error from last readKind
stack []uint64 // list sizes
uintbuf [32]byte // auxiliary buffer for integer decoding
kind Kind // kind of value ahead
byteval byte // value of single byte in type tag
limited bool // true if input limit is in effect
}
List():确认下一个值是列表,从外层列表的“剩余额度”里扣掉它的大小,再把它的大小压栈;- 每读一个元素,栈顶的剩余字节数相应减少;
ListEnd():要求当前列表正好读完(还有剩余就返回errNotAtEOL),然后出栈。
类比:拆套娃。 每拆开一层,就在本子上记下“这层还剩多少”;这一层拆完,就划掉一行。
巧思 4:struct tag 和代码生成
rlp:"optional":字段可以缺失,后面的字段也必须是 optional。新硬分叉往区块头里加字段就靠它:types.Header里的BaseFee(伦敦)、WithdrawalsHash(上海)、BlobGasUsed、ExcessBlobGas、ParentBeaconRoot(坎昆)都带这个标签,老区块没有这些字段也能正常解码;rlp:"tail":最后一个切片字段吞掉剩余所有元素(discv4 报文用它做前向兼容);rlp:"nil"、rlp:"-":空值解码为 nil 指针、忽略该字段;- rlpgen:反射虽然通用,但慢。像
Header、Log这样的热点类型,用//go:generate go run ../../rlp/rlpgen -type Header -out gen_header_rlp.go生成专用的编码代码,性能更好。
和 JSON-RPC 的联系:
eth_sendRawTransaction的参数、eth_getProof返回的每个证明节点,都是 RLP 编码的字节。第三部分trace_rawTransaction示例里的signedTx.MarshalBinary(),对 Legacy 交易来说就是交易字段列表的 RLP 编码;对新类型交易则是“类型字节 + RLP”。
4.4 Trie(MPT)与状态存储:带指纹的档案柜
它解决什么问题
以太坊要保存海量的“键 → 值”:地址 → 账户、存储槽 → 值、交易序号 → 交易、交易序号 → 收据。它要求:
- 查得快:按 key 快速找到 value;
- 能防篡改:整个集合有一个“总指纹”(根哈希)写进区块头,任何一个值被改,指纹都会变;
- 能证明:不用给出全部数据,也能证明“某个 key 的值就是这个”;
- 能留历史:改一个账户时不复制整棵树,最近区块的状态还能查。
Merkle Patricia Trie(MPT)把三种数据结构组合起来,同时满足这四点。
三块积木
积木 1:Trie(前缀树)——按字母一路往下找

in、inn、int 共用前缀 in 这条路。查找步数只和 key 的长度有关,和数据总量无关。缺点是:没有公共前缀的长 key 会拉出一长串“单线”节点。
积木 2:Patricia(压缩前缀树)——把单线路径压成一个节点

“没有分岔的路就合并成一段”,大大减少了节点数量。
积木 3:Merkle 树——每个节点都带“封条指纹”

类比:档案柜的封条。 每个文件夹的封条上写着“里面所有东西”的指纹,柜子总封条上的指纹由各个文件夹的封条算出来。有人偷偷改了一张纸,从那个文件夹一直到总封条,指纹全都会变。
组合起来:用 Patricia 的结构组织 key,用 Merkle 的方式给每个节点算哈希,父节点不存子节点本身,而是存子节点的哈希。根节点的哈希就是区块头里的 stateRoot、transactionsRoot、receiptsRoot。
四种节点(trie/node.go)
type node interface {
cache() (hashNode, bool)
encode(w rlp.EncoderBuffer)
fstring(string) string
}
type (
fullNode struct {
Children [17]node // Actual trie node data to encode/decode (needs custom encoder)
flags nodeFlag
}
shortNode struct {
Key []byte
Val node
flags nodeFlag
}
hashNode []byte
valueNode []byte
)
type nodeFlag struct {
hash hashNode // cached hash of the node (may be nil)
dirty bool // whether the node has changes that must be written to the database
}
记一个口诀:
| 节点 | 口诀 | 说明 |
|---|---|---|
fullNode | 十六路分岔口 + 一个本站值 | key 被拆成一个个半字节(nibble,0~f),前 16 个孩子对应下一个 nibble;第 17 格存“恰好在这里结束”的 key 的值 |
shortNode | 一段共享路径 | Key 末尾带终结符 16 的是叶子节点(Val 是 valueNode);否则是扩展节点(Val 指向下一个节点) |
hashNode | 还没取回的快递单号 | 只有哈希,用到时才去数据库加载(懒加载) |
valueNode | 包裹本身 | 实际的值,比如 RLP 编码的账户 |
下面这张图把四种节点、前缀标记和根哈希画在了一起,是理解 MPT 最好的一张图:

图里 4 个账户的 key(a711355、a77d337、a7f9365、a77d397)都以 a7 开头,于是:
- 根是一个扩展节点,存共享前缀
a7; - 下面是分支节点,按下一个 nibble 分成
1、7、f三路; 1和f两路下面各只剩一个账户,直接用叶子节点收尾;7这一路的两个 key 还共享d3,于是再接一个扩展节点 + 分支节点;- 根节点的 Keccak256 哈希就是
stateRoot。
HP 编码:节点自己“说明”自己是什么
叶子和扩展节点都是 [路径, 值] 两个元素,怎么区分?路径是奇数个 nibble 时又怎么凑成整字节?答案是 HP(Hex-Prefix)编码:在路径前面加一个“标记 nibble”。
| 前缀 nibble | 节点类型 | 路径 nibble 个数 |
|---|---|---|
0 | 扩展节点 | 偶数(后面再补一个 0 凑整字节) |
1 | 扩展节点 | 奇数(第一个 nibble 直接塞进首字节低位) |
2 | 叶子节点 | 偶数 |
3 | 叶子节点 | 奇数 |
例子:叶子 [1,2,3,4,5](奇数)编码为 0x31 0x23 0x45;扩展 [0,1,2,3,4,5](偶数)编码为 0x00 0x01 0x23 0x45。
key 在不同场合有三种形态(trie/encoding.go):
| 编码 | 形式 | 用在哪里 |
|---|---|---|
| KEYBYTES | 原始字节 | 对外 API(trie.Get(key)) |
| HEX | 每个字节存一个 nibble,叶子末尾加终结符 16 | 内存中操作,方便逐个 nibble 走树 |
| COMPACT | 即 HP 编码 | 编码节点、写入数据库,省空间 |
// trie/encoding.go
func hexToCompact(hex []byte) []byte {
terminator := byte(0)
if hasTerm(hex) { // 末尾是 16 → 叶子
terminator = 1
hex = hex[:len(hex)-1]
}
buf := make([]byte, len(hex)/2+1)
buf[0] = terminator << 5 // the flag byte
if len(hex)&1 == 1 {
buf[0] |= 1 << 4 // odd flag
buf[0] |= hex[0] // first nibble is contained in the first byte
hex = hex[1:]
}
decodeNibbles(hex, buf[1:])
return buf
}
一个小巧合:HEX 编码的终结符
16正好对应fullNode.Children[16],也就是“本站值”那一格,插入时不需要额外判断。
插入与删除:一路往下走,遇到分岔就“劈开”
insert(trie/trie.go)是一个递归函数,按当前节点的类型分情况处理:

删除是插入的逆过程,关键在于保持树的“最简形态”:子节点删除后如果剩下的还是 shortNode,就和父节点合并成一段路径;fullNode 只剩一个孩子时,退化成 shortNode。这样同一组数据永远只对应一棵形状唯一的树,根哈希才是确定的。
算哈希:小于 32 字节的节点直接内嵌(hasher.go)
// trie/hasher.go(shortNode 分支,fullNode 同理)
case *shortNode:
enc := h.encodeShortNode(n)
if len(enc) < 32 && !force {
// Nodes smaller than 32 bytes are embedded directly in their parent.
buf := make([]byte, len(enc))
copy(buf, enc)
return buf
}
hash := h.hashData(enc)
n.flags.hash = hash
return hash
- 节点先做 RLP 编码;
- 编码后不足 32 字节的小节点不单独存,直接内嵌进父节点(存一个 32 字节的哈希反而更长);
- 否则计算
Keccak256(RLP编码),父节点里就用这个哈希代替它; - 根节点调用时
force=true,所以根节点一定会被哈希,这就是stateRoot; - 一次改动很多(超过 100 个未哈希的节点)时,fullNode 的 16 个孩子会并行计算哈希。
提交:Commit 只“打包”,不写库
这是新版和老资料差别最大的地方之一:
// trie/trie.go
func (t *Trie) Commit(collectLeaf bool) (common.Hash, *trienode.NodeSet)
Commit 自底向上算完哈希后,不再直接写数据库,而是把所有变动的节点收集进一个 NodeSet:
// trie/trienode/node.go
type NodeSet struct {
Owner common.Hash
Leaves []*leaf
Nodes map[string]*Node // key 是节点在树中的“路径”,不是哈希
Origins map[string][]byte
updates int // the count of updated and inserted nodes
deletes int // the count of deleted nodes
}
账户树和各个合约的存储树各自产生一个 NodeSet,汇总成 MergedNodeSet,最后交给存储层 triedb.Database.Update 统一落盘。计算和存储解耦之后,存储层就可以换成更高效的组织方式,也就是下面的 pathdb。
看清这一步,你就理解了 RLP、Trie、数据库三者的关系:Trie 的每个节点先 RLP 编码,再由 triedb 写进 ethdb。
状态存储层:hashdb 与 pathdb
| 方案 | 数据库的 key | 特点 | 适合 |
|---|---|---|---|
hash scheme(triedb/hashdb) | 节点哈希 | 同一个节点可以被多个版本共享,能存所有历史版本,但很难删除旧数据(要靠引用计数) | 归档节点 |
path scheme(triedb/pathdb,默认) | 节点在树中的路径 | 磁盘上每个路径只存一个最新版本,旧数据被自然覆盖,不需要额外的修剪 | 普通全节点 |
v1.17.8 在新建数据库时默认使用 path scheme(core/rawdb/accessors_trie.go 的 ParseStateScheme),状态节点的 key 形如 "A" + 路径(账户树)和 "O" + 账户哈希 + 路径(存储树)。
pathdb 的分层结构:

// triedb/pathdb/database.go(Update 的最后)
// Keep 128 diff layers in the memory, persistent layer is 129th.
// - head layer is paired with HEAD state
// - head-1 layer is paired with HEAD-1 state
// - head-127 layer(bottom-most diff layer) is paired with HEAD-127 state
// - head-128 layer(disk layer) is paired with HEAD-128 state
return db.tree.cap(root, maxDiffLayers)
- 每处理一个区块,就在最上面叠一层 diff layer,只记录这个区块改了哪些节点和账户;
- 超过 128 层时,最底下那层合并进 disk layer。合并前,先把“怎么撤销这次改动”写进 state history;
- 发生深度重组、需要回到更早的状态时,用 state history 一层层倒推回去。
这就解释了第三部分的那个“常见误区”:普通全节点能直接查询的只有最近约 128 个区块的状态(内存里的 diff layer + 磁盘上的 disk layer),所以
eth_getBalance查很久以前的区块会报missing trie node,需要归档节点。
结构共享:为什么不必每个区块复制一遍状态

账户 175 的余额从 27 变成 45 时,只有从这个账户到根的那一条路径上的节点需要新建,其他节点新旧两棵树共用。pathdb 的 diff layer 正是把这“一条路径”记下来,所以每个区块都有自己完整的 stateRoot,却只需存很少的增量。
默克尔证明(proof.go):eth_getProof 的原理
- 生成:
Trie.Prove(key, proofDb)从根走到目标,把路径上每个节点的 RLP 编码收集起来(内嵌的小节点已经包含在父节点里,不单独放)。这就是第三部分eth_getProof返回的accountProof/storageProof。 - 验证:
VerifyProof(rootHash, key, proofDb):- 期望哈希 = 区块头里的根哈希;
- 按期望哈希取出证明里的节点并解码;
- 按 key 往下走一步,拿到下一个节点的哈希作为新的期望哈希;
- 重复,直到走到
valueNode(拿到值)或者走到空(证明 key 不存在)。
类比:你不用把整栋楼的档案搬给别人看,只需要给出“从总封条到你这份档案”的那一串封条。对方手里有总封条的指纹(区块头),逐层核对就能确信,既不用信任你,也不用信任节点。
另外还有
VerifyRangeProof,可以证明“某个区间内的所有 key 就是这些”,4.8 讲的 snap 同步就靠它按区间批量下载状态。
StateTrie:先把 key 哈希一下
状态树实际使用的是 StateTrie(trie/secure_trie.go,老名字叫 SecureTrie):
// 账户的 key 是地址的哈希,不是地址本身
t.trie.Get(crypto.Keccak256(address.Bytes()))
这样无论地址长什么样,路径长度都固定是 64 个 nibble,攻击者没办法刻意构造很长的公共前缀把树变深。代价是丢失了原始 key,所以可以选择额外保存“哈希 → 原始 key”的映射(preimage)。这也是 3.3 节 eth_getStorageAt 读 mapping 时,要先自己算 keccak256 的原因之一。
StateDB:EVM 眼里的“世界”
core/state.StateDB 是 EVM 和状态树之间的中间层:EVM 只管调用 GetBalance、SetState、AddBalance 这样的方法,StateDB 负责缓存、记账、最后提交成新的状态根。
- stateObject:每个被访问过的账户对应一个 stateObject,里面缓存着账户数据、合约代码和多级存储缓存(原始值、已修改、待提交);
- journal(修改日志):每次修改状态都记一条“怎么撤销”;
- 快照与回滚:
// core/state/journal.go
func (j *journal) snapshot() int {
id := j.nextRevisionId
j.nextRevisionId++
j.validRevisions = append(j.validRevisions, revision{id, j.length()})
return id
}
// revertToSnapshot:找到书签位置,从日志末尾倒着逐条执行撤销,直到书签处
类比:Word 的撤销栈。 快照只是插入一个书签;回滚就是从栈顶往回一条条撤销,直到书签位置。EVM 每进入一层合约调用就打一个书签,这一层失败就只撤销这一层的修改(4.10)。
4.5 ethdb 与 rawdb:仓库和货品编号
它解决什么问题
上层的 trie、区块链只想要一个简单的 KV(键值)存储,不关心底下用的是哪种数据库。ethdb 定义统一的接口,core/rawdb 规定“什么数据用什么 key 存在哪”。
类比:
ethdb是仓库的货架标准,rawdb是货品编号规则,freezer 是只进不改的冷库档案柜。

接口(ethdb/database.go,节选)
type KeyValueReader interface {
Has(key []byte) (bool, error)
Get(key []byte) ([]byte, error)
}
type KeyValueWriter interface {
Put(key []byte, value []byte) error
Delete(key []byte) error
}
type KeyValueStore interface {
KeyValueReader
KeyValueWriter
KeyValueStater
KeyValueSyncer
KeyValueRangeDeleter
Batcher // NewBatch():批量写
Iteratee // NewIterator(prefix, start):按前缀遍历
Compacter // Compact(start, limit):手动压缩
io.Closer
}
// Database = 普通键值库 + 冷数据库
type Database interface {
KeyValueStore
AncientStore
}
为什么需要 Batch? 一个区块提交时要写成百上千条数据。逐条写既慢,又可能写到一半崩溃、留下不完整的状态。Batch 先在内存里攒着,再一次性原子地写入。
三种实现,默认是 Pebble
| 实现 | 包 | 说明 |
|---|---|---|
| Pebble(默认) | ethdb/pebble | CockroachDB 团队开发的 LSM 键值库,纯 Go 实现 |
| LevelDB | ethdb/leveldb | 老节点沿用,--db.engine=leveldb 可指定 |
| 内存库 | ethdb/memorydb | map[string][]byte 加读写锁,用于测试 |
选择逻辑在 node/database.go:已有数据库就沿用原来的类型;全新安装时:
// No pre-existing database, no user-requested one either. Default to Pebble.
log.Info("Defaulting to pebble as the backing database")
return newPebbleDBDatabase(o.directory, o.Cache, o.Handles, o.MetricsNamespace, o.ReadOnly)
这类 LSM 数据库有几个共同特点:key 按字节序排序、支持前缀迭代、同一时间只允许一个进程打开。最后这一条解释了为什么 Geth 运行时,你不能再起一个进程直接打开它的 chaindata。
rawdb:货品编号规则
core/rawdb/schema.go 规定了每类数据的 key 格式:
| 前缀 | key 结构 | 存什么 |
|---|---|---|
h | h + 区块号(8字节大端) + 区块哈希 | 区块头 |
h … n | h + 区块号 + n | 这个高度的规范链区块哈希 |
H | H + 区块哈希 | 区块哈希 → 区块号 |
b | b + 区块号 + 区块哈希 | 区块体(交易列表等) |
r | r + 区块号 + 区块哈希 | 收据 |
l | l + 交易哈希 | 交易 → 所在区块(eth_getTransactionByHash 靠它) |
c | c + 代码哈希 | 合约字节码 |
A / O | A + 路径 / O + 账户哈希 + 路径 | pathdb 的账户树、存储树节点 |
fm- | fm-r、fm-b、fm-p …… | 日志索引 filtermaps(4.9) |
区块号用 8 字节大端编码,是为了让 key 的字典序正好等于区块号的数字顺序,按区块号范围遍历时就是一段连续的数据。
把第三部分串起来:
eth_getTransactionByHash先用l + 交易哈希查出它在哪个区块,再用b + 区块号 + 区块哈希取出区块体,从中拿到这笔交易。
freezer:冷数据档案库
已经“不可能再被重组”的老数据,会被搬进只追加的 freezer(ancient store):
| freezer | 存放的表 |
|---|---|
chain | headers、hashes、bodies、receipts、bals(区块访问列表) |
state | state history(pathdb 回滚用) |
- 每分钟检查一次,把比
max(最终确定的区块, 链头 − 90000)更老的区块搬过去,每批最多 3 万个; - freezer 是按区块号顺序追加写入的平面文件,读取快、空间省,也不会给 LSM 数据库增加压缩负担;
- 区块体和收据属于同一个“尾部分组”,可以一起从头部删掉。这就是“历史数据过期”(只保留近期历史)功能的基础。
4.6 rpc:通往外部世界的大门(重点)
终于到了和前三部分直接相关的模块。第二部分我们说“Geth 用反射把 BlockNumber 注册成 eth_blockNumber”,这一节就拆开 rpc 包,看它到底怎么做到的。
它解决什么问题
把一个普通 Go 对象的导出方法“挂”到网络上(HTTP / WebSocket / IPC / 进程内),让外部能用 JSON 远程调用;同时支持服务端主动推送(订阅)。
类比:餐厅点菜系统。
RegisterName:把厨师(service)和他会做的菜(方法)登记进菜单;- codec(编解码器):服务员,把顾客的话(JSON)翻译成后厨的单子(Go 参数),再把做好的菜(返回值)端回去;
- handler:每张桌子配一个专属领班,负责这张桌子的所有点单、上菜和“叫号”;
- 订阅:“菜好了叫号”——顾客留下号码牌(订阅 ID),后厨有新菜就主动送过去。
包结构

传输方式和业务逻辑完全解耦:同一个 eth_getBalance 实现,不管请求从哪个通道进来,最后都交给同一个 handler 处理。
一条消息的样子:jsonrpcMessage
请求、响应、订阅通知,在 Geth 里都是同一个结构体(rpc/json.go):
type jsonrpcMessage struct {
Version string `json:"jsonrpc,omitempty"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Error json.RawMessage `json:"error,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
}
- 有
ID又有Method→ 一次调用(call); - 没有
ID只有Method→ 通知(notification,订阅推送就是这种); - 有
ID没有Method→ 响应。
Params 和 Result 用的是 json.RawMessage:先不解析,等找到了要调用的 Go 方法、知道了参数类型,再按类型精确解码。
什么样的方法能被远程调用
注册时,rpc 包用反射扫描对象的所有方法,只挑出符合规则的(rpc/service.go 的 newCallback):
- 方法必须导出(大写开头);
- 第一个参数可以是
context.Context(不算入 JSON 参数); - 返回值最多 2 个:没有返回值、只返回结果、只返回 error、或者
(结果, error),error 必须在最后; - 可选参数用指针表示,放在最后:客户端没传,就补一个
nil(只有指针类型的尾部参数才允许省略); - 订阅方法:第一个参数必须是
context.Context,返回(*rpc.Subscription, error)。
// 这些都能被注册成 JSON-RPC 方法
func (s *CalcService) Add(a, b int) (int, error)
func (s *CalcService) AddMod(a, b int, mod *int) (int, error) // mod 可选
// 订阅方法
func (s *MyService) NewBlocks(ctx context.Context) (*rpc.Subscription, error)
回头看第三部分:
eth_estimateGas的block参数“可选”、eth_call的stateOverrides“可选”,在 Go 源码里就是指针参数。
注册:从 Go 方法到 eth_xxx
// rpc/service.go
type service struct {
name string // name for service
callbacks map[string]*callback // registered handlers
subscriptions map[string]*callback // available subscriptions/notifications
}
type callback struct {
fn reflect.Value // the function
rcvr reflect.Value // receiver object of method, set if fn is method
argTypes []reflect.Type // input argument types
hasCtx bool // method's first argument is a context (not included in argTypes)
errPos int // err return idx, of -1 when method cannot return error
isSubscribe bool // true if this is a subscription callback
}

命名规则只靠两个小函数:
// 注册时:方法名首字母变小写 GetBalance → getBalance
func formatName(name string) string {
ret := []rune(name)
if len(ret) > 0 {
ret[0] = unicode.ToLower(ret[0])
}
return string(ret)
}
// 查找时:按第一个 "_" 切开 "eth_getBalance" → ("eth", "getBalance")
func serviceAndMethod(name string) (service, method string, ok bool) {
return strings.Cut(name, serviceMethodSeparator) // serviceMethodSeparator = "_"
}
请求来了,先切出命名空间,再查 services["eth"].callbacks["getBalance"],两次 map 查找就找到了要调用的方法。这就是第二部分那条“命名规律”的源码出处。
一个有趣的细节:
eth命名空间里,EthereumAPI.Syncing是普通方法(对应eth_syncing),DownloaderAPI.Syncing是订阅方法(对应eth_subscribe("syncing"))。两个方法同名,但分别存在callbacks和subscriptions两张表里,互不冲突。另外,
NewServer()一创建,就自动注册了一个命名空间为rpc的元信息服务,调用rpc_modules能看到节点开放了哪些命名空间。
谁注册了哪些命名空间
| 命名空间 | Go 结构体 | 注册位置 | 举例 |
|---|---|---|---|
eth | EthereumAPI | internal/ethapi/backend.go 的 GetAPIs | eth_gasPrice、eth_feeHistory、eth_syncing |
eth | BlockChainAPI | 同上 | eth_chainId、eth_getBalance、eth_call、eth_estimateGas、eth_getProof |
eth | TransactionAPI | 同上 | eth_getTransactionByHash、eth_sendRawTransaction |
eth | EthereumAccountAPI | 同上 | eth_accounts |
eth | FilterAPI | eth/filters,启动时由 cmd/utils 注册 | eth_getLogs、eth_newFilter、eth_subscribe("logs") |
eth | DownloaderAPI | eth/backend.go 的 APIs() | eth_subscribe("syncing") |
txpool | TxPoolAPI | GetAPIs | txpool_status、txpool_content |
debug | DebugAPI + tracers 的 API | GetAPIs + eth/tracers | debug_traceTransaction、debug_traceCall |
net | NetAPI | eth/backend.go | net_version、net_peerCount |
web3 / admin | web3API / adminAPI | node/api.go | web3_clientVersion、admin_peers |
engine | ConsensusAPI | eth/catalyst,需要 JWT 认证 | engine_newPayloadV4、engine_forkchoiceUpdatedV3 |
在 v1.17.8 中没有
personal命名空间,trace_*、erigon_*也不是 Geth 实现的。这些调用都会落进上面“查不到 callback”的分支,返回-32601。
一次请求的完整旅程(以 HTTP 为例)

关键步骤:
- 入口与中间件(
node/rpcstack.go):HTTP 请求先经过 gzip、Host 白名单(--http.vhosts)、CORS(--http.corsdomain)几层中间件;如果是 authrpc(8551 端口),还多一层 JWT 校验。 - 请求校验(
rpc/http.go):拒绝 PUT/DELETE,body 超过 5 MiB 返回 413,Content-Type 不对返回 415;一个不带 body 的 GET 直接返回 200,正好用来做健康检查。 - 解析参数:找不到方法返回
-32601;参数个数或类型不对返回-32602;方法内部 panic 会被捕获,返回-32603。 - 反射调用:
callback.call拼出[接收者, ctx, 参数...],执行fn.Call(...);返回的 error 不为空就转成错误响应,否则把结果 JSON 编码进result。 - 超时:如果方法执行超过了 HTTP 的写超时(默认 30 秒),提前返回
-32002 request timed out,不会让连接一直挂着。
WebSocket 和 IPC 的不同之处在于连接是长期保持的:Server.ServeCodec 会给这条连接建一个“客户端对象”,复用 rpc.Client 的 dispatch 循环来读写消息,然后同样交给 handler。所以服务端和客户端其实共享了同一套连接管理代码。
并发与批量:容易被误解的两点
| 场景 | Geth 的做法 |
|---|---|
| 单条请求 | 每条请求一个 goroutine 并发执行,所以响应顺序可能和请求顺序不同,客户端要用 id 配对 |
| 批量请求(数组) | 整个批次放进一个 goroutine,按顺序逐条执行 |
批量请求还有几道“安全阀”(node/defaults.go,可以用 --rpc.batch-request-limit 等参数调整):
| 限制 | 默认值 | 超出后 |
|---|---|---|
| 一批最多多少条 | 1000 | 整批返回 -32600 batch too large |
| 一批响应累计大小 | 25 MB | 剩下的请求统一返回 -32003 response too large |
空数组 [] | — | 返回 -32600 empty batch |
| WebSocket 单条消息 | 32 MiB | 断开 |
订阅的精妙之处:“先给号码,再开始叫号”

// rpc/handler.go(节选):先登记订阅、写回带 ID 的响应,再激活
h.addSubscriptions(cp.notifiers)
if answer != nil {
responded.Do(func() { ... h.conn.writeJSON(writeCtx, answer, false) ... })
}
// Enable notification sending of subscriptions, since the response with
// subscription ID has now been sent.
for _, n := range cp.notifiers {
n.activate()
}
为什么要这么麻烦?如果先激活,新区块的推送可能比“订阅 ID 响应”更早到达客户端:客户端收到一条推送,却不知道它属于哪个订阅。“先给号码、再叫号”保证了顺序。
v1.17.8 的 eth 命名空间提供这些订阅:newHeads、logs、newPendingTransactions、transactionReceipts(新增)、syncing。订阅在客户端调用 eth_unsubscribe 或连接断开时结束。
客户端:rpc.Client 如何做到“一条连接、多个调用”
ethclient 的每次调用,最终都走到 rpc.Client.CallContext(rpc/client.go):
newMessage:用自增计数器生成id,把参数json.Marshal成params;- HTTP:
sendHTTP直接发一个 POST,同步读回响应。没有后台循环,所以 HTTP 客户端不支持订阅,调用Subscribe直接返回ErrNotificationsUnsupported; - WebSocket / IPC:交给后台的 dispatch 协程;
- 等待响应:有
error字段就返回错误,result为空返回ErrNoResult,否则json.Unmarshal到你传入的结果指针里。

长连接时,很多 goroutine 可能同时用同一个 client,又共用一条连接。dispatch 协程用 channel 当锁:
- 写:调用方把请求发到
reqInit通道,被 dispatch 接收就相当于拿到写锁;写完通过reqSent释放。所以写操作天然串行,两个请求的字节不会交错; - 读:单独的
read协程不停读连接,交给 dispatch。dispatch 按id找到等待者,把响应塞进它的resp通道;如果是eth_subscription通知,就按订阅 ID 投递给对应的订阅; - 超时:调用方的 ctx 超时后,通过
reqTimeout通知 dispatch 把这个id从等待表里删掉; - 订阅缓冲:每个客户端订阅有一个缓冲队列,积压超过 20000 条(
maxClientSubscriptionBuffer)就返回ErrSubscriptionQueueOverflow并退订。所以第三部分订阅示例里,处理推送的逻辑不能太慢。
BatchCallContext 则把多条请求打包成一个 JSON 数组发出去,回来后按 id 把结果填回各自的 BatchElem。ethclient.BlockByNumber 遇到有叔块的老区块时,就是用它一次性批量取回所有叔块。
节点侧:--http.api 是怎么生效的
node 包负责把 rpc.Server 挂到端口上:HTTP 8545、WS 8546、authrpc 8551,默认都只监听 localhost。--http.api 的白名单在 node/rpcstack.go 的 RegisterApis 里生效:
allowList := make(map[string]bool)
for _, module := range modules {
allowList[module] = true
}
for _, api := range apis {
if allowList[api.Namespace] || len(allowList) == 0 {
if err := srv.RegisterName(api.Namespace, api.Service); err != nil {
注意三点:
- 白名单按命名空间整体过滤,不能只开放某几个方法;
- 没写
--http.api时,Geth 默认只开放net和web3,所以自己搭节点一定要显式写上eth; - IPC 不做白名单过滤,所有命名空间都开放,但 socket 文件权限是
0600,只有本机同一用户能访问。
engine 命名空间只注册在 8551 端口上,请求必须带 HS256 签名的 JWT,并且签发时间(iat)与当前时间相差不能超过 60 秒。
把第二、三部分串起来:
client.BalanceAt(ctx, addr, nil)→rpc.Client.CallContext(ctx, &result, "eth_getBalance", addr, "latest")→ JSON 经 HTTP 发出 → Geth 中间件 →rpc.Server→handler→ 切出eth+getBalance,反射调用BlockChainAPI.GetBalance→ 从该区块的stateRoot出发查状态树(4.4)→ 树节点从数据库读出(4.5) → 返回"0x..."→ 客户端json.Unmarshal成*big.Int。
4.7 p2p:节点如何找到彼此、安全地交谈
JSON-RPC 是“你和节点”之间的对话,p2p 是“节点和节点”之间的对话。你用 eth_sendRawTransaction 发出的交易,靠 p2p 网络传遍全球。
总体结构
p2p 包只提供通用的点对点能力,具体业务协议(eth、snap)以 Protocol 的形式“插”进来。每个节点默认同时使用两个同号端口 30303:
| 端口 | 协议 | 用途 | 类比 |
|---|---|---|---|
| UDP 30303 | discv4 + discv5(默认都开启,共用一个 socket) | 找节点:问路、打招呼 | 发短信问路 |
| TCP 30303 | RLPx | 传数据:在加密通道上跑 eth / snap 协议 | 打加密电话 |

默认参数(node/defaults.go、p2p/server.go):MaxPeers = 50,DialRatio = 3,所以最多主动连出 50/3 = 16 个节点,其余名额留给别人连进来。
① 节点身份:enode 与 ENR
- 节点 ID =
Keccak256(公钥),32 字节(type ID [32]byte)。老版本直接用 64 字节公钥当 ID,现在统一成了哈希; - ENR(以太坊节点记录,EIP-778):一张自己签名的电子名片,最大 300 字节,里面记录
id(身份方案)、ip、tcp、udp等键值对,还有一个版本号seq。IP 变了就重新签一张、seq加一,别人看到更高的seq就知道该更新了; - eth 协议会在 ENR 里写入一个
eth条目,内容是 ForkID(当前硬分叉的指纹)。发现阶段就能按 ForkID 过滤掉不同链、不同分叉的节点,不用等到握手才发现“不是一路人”。
类比:ENR 就是一张盖了自己私章的名片。谁都可以转发它,但没人能伪造,因为私章(私钥签名)只有你有。
节点数据库(p2p/enode/nodedb.go):一个独立的 LevelDB,相当于“名片夹”,记录见过的节点、上次 ping/pong 的时间等。超过 24 小时没见过的节点,会被每小时运行一次的清理任务删除。
② 路由表(discover/table.go):按“亲疏远近”分层的通讯录
以太坊的节点发现借鉴了 Kademlia 算法,核心是 XOR 距离:两个 ID 按位异或,看“最高的不同位”在第几位。
// p2p/enode/node.go:两个 ID 的对数距离
func LogDist(a, b ID) int {
lz := 0
for i := 0; i < len(a); i += 8 {
ai := binary.BigEndian.Uint64(a[i : i+8])
bi := binary.BigEndian.Uint64(b[i : i+8])
x := ai ^ bi
if x == 0 { lz += 64 } else { lz += bits.LeadingZeros64(x); break }
}
return len(a)*8 - lz
}
| 参数 | 值 | 含义 |
|---|---|---|
bucketSize | 16 | 每个桶最多 16 个节点 |
maxReplacements | 10 | 每个桶另有 10 个“候补” |
nBuckets | 256/15 = 17 | 只为最近的 17 个距离段建桶,更远的都塞进第 0 个桶 |
alpha | 3 | 查找时同时问 3 个节点 |
bucketIPLimit | 每桶同一 /24 网段最多 2 个 | 防日蚀攻击:攻击者很难用同一机房的大量节点占满你的通讯录 |
tableIPLimit | 整张表同一 /24 网段最多 10 个 | 同上 |
| 刷新间隔 | 15~30 分钟随机 | 定期 lookup 补充新节点 |
类比:你的通讯录按“和你有多亲”分层,每层最多记 16 个人,另有 10 人的候补名单。要找某人时,同时问通讯录里离目标最近的 3 个朋友:“你认识谁离他更近?”再去问他们推荐的人……每问一轮都离目标更近,问不到更近的人就结束。
查找(lookup):从路由表取离目标最近的节点 → 同时向最多 3 个发 findnode → 把返回的节点按距离插入结果(只保留最近的 16 个)→ 直到没有更近的可问。刷新时会做一次“查找自己”(lookupSelf,用来填满离自己近的桶)和 3 次“随机查找”。
存活检查(revalidation):新版用 fast / slow 两个列表定期 ping 桶里的节点。新加入的节点在 fast 列表里每 3 秒左右被检查一次,表现稳定后转到 slow 列表(间隔 3 倍)。每次有回应就给它的“存活分”加一,没回应就把分数除以 3,降到 0 就踢出桶、由候补顶上。如果 pong 里带的 ENR 版本号更高,还会顺手拉取新名片。
③ UDP 报文(discv4):六种短信
| 报文 | 含义 |
|---|---|
Ping | 你在吗?(带上自己 ENR 的 seq) |
Pong | 我在(回显对方看到的地址,借此可以知道自己的公网 IP) |
Findnode | 你认识谁离目标 X 近? |
Neighbors | 我认识这些人(每个包最多 12 个节点) |
ENRRequest | 把你最新的名片给我 |
ENRResponse | 这是我的名片 |
报文格式是 hash(32) || 签名(65) || 类型(1) || RLP(数据):
// p2p/discover/v4wire/v4wire.go(节选)
b.Write(headSpace)
b.WriteByte(req.Kind())
rlp.Encode(b, req)
packet = b.Bytes()
sig, err := crypto.Sign(crypto.Keccak256(packet[headSize:]), priv)
copy(packet[macSize:], sig)
hash = crypto.Keccak256(packet[macSize:])
copy(packet, hash)
几个设计亮点:
- 报文是明文,但带签名:接收方用签名恢复出发送方公钥,所以包里根本不用放公钥,节点 ID 也无法伪造;
- 单包不超过 1280 字节,保证在最小 MTU 下也不会被分片;
- 防放大攻击:只回答 24 小时内 ping/pong 过(“绑定”过)的节点发来的
findnode,没绑定过就先 ping 一下。否则攻击者可以伪造源地址发一个很小的 findnode,诱使你向受害者发出大得多的 neighbors 包; - 请求超时 500 毫秒,报文 20 秒后过期(所以本机时钟不能偏差太大)。
discv5 是新一代发现协议,支持按“主题”查找、加密的报文头等。Geth 现在默认 v4、v5 同时开启,两者共用 30303 UDP 端口,v4 识别不了的包交给 v5 处理。
④ NAT 穿透:让外网找得到你
家里的电脑躲在路由器后面,外网无法主动连进来:
| 打洞前 | 打洞后 |
|---|---|
![]() | ![]() |
p2p/nat 支持这些方式(--nat 参数):
| 值 | 含义 |
|---|---|
any(默认) | 同时尝试 UPnP 和 NAT-PMP,谁先成功用谁 |
upnp / pmp | 指定协议,让路由器建立端口映射 |
extip:1.2.3.4 | 直接告诉 Geth 公网 IP(云服务器常用) |
stun | 通过 STUN 服务器探测公网地址 |
none | 不做 NAT 处理 |
端口映射每 10 分钟续期一次。
类比:公司前台转接分机。 外线只知道总机号码,前台登记“这个号转到 30303 分机”之后,电话才接得进来。
⑤ RLPx 加密握手:在公开场合约定暗号
找到节点后,要建立加密的 TCP 通道,核心是 ECDH 密钥交换:

类比:混颜料。 双方先公开约定一种底色,各自偷偷加入自己的秘密颜色,把混合结果公开交换;再各自往对方的结果里加入自己的秘密颜色,最终得到相同的颜色。旁观者只看到中间结果,很难反推出最终颜色。

// p2p/rlpx/rlpx.go:会话密钥派生
ecdheSecret, err := h.randomPrivKey.GenerateShared(h.remoteRandomPub, sskLen, sskLen)
sharedSecret := crypto.Keccak256(ecdheSecret, crypto.Keccak256(h.respNonce, h.initNonce))
aesSecret := crypto.Keccak256(ecdheSecret, sharedSecret)
s := Secrets{
remote: h.remote.ExportECDSA(),
AES: aesSecret, // 对称加密密钥(AES-CTR)
MAC: crypto.Keccak256(ecdheSecret, aesSecret), // 完整性校验密钥
}
用每次连接都重新生成的临时密钥来算共享秘密,就算节点的长期私钥日后泄露,过去录下的通信也解不开,这叫前向安全。
握手完成后,每条消息按帧收发:
header(16 字节:3 字节帧长 + 固定头,加密)| header-mac(16)| 帧数据(RLP(消息码) + 内容,补齐到 16 字节倍数,加密)| frame-mac(16)
如果双方的 devp2p 版本 ≥ 5,消息还会先做 Snappy 压缩再加密;解压前会先检查解压后的长度,防止“压缩炸弹”。
⑥ dialScheduler:外卖平台的派单员
p2p/dial.go 里的 dialScheduler 是一个事件循环,决定“现在主动连谁”:
类比:外卖平台的派单员。 手上的骑手名额有限;VIP 老客户(静态节点)优先派;刚联系过的人 35 秒内不再打扰。
- 计算空闲名额:
(最大出站数 − 已连出数) × 2,再减去正在拨号的数量。乘 2 是“超额拨号”,因为不少拨号会失败; - 静态节点(
--staticnodes或配置里写死的节点)优先占用名额; - 还有名额,才从节点发现的候选流里取动态节点;
- 每个候选都要检查:不是自己、没在拨号、没连上、不在网段黑名单、不在 35 秒冷却期内;
- 通过后记入冷却表,起一个 goroutine 拨号,最后调用
srv.SetupConn。
入站方向也有限流:同时处于握手阶段的入站连接最多 50 个;同一个公网 IP 30 秒内只能连进来一次(局域网 IP 不受限制)。
⑦ peer.go:一根光纤跑多个频道
一条加密连接上会同时跑 eth、snap 等多个子协议,每个协议分到一段消息码区间:
0x00 ~ 0x0f 基础协议:handshake(0x00) / disconnect(0x01) / ping(0x02) / pong(0x03)
0x10 ~ 按协议名排序后依次分配:eth 占一段,snap 接在后面
- 双方握手时交换各自支持的协议和版本(caps),同名协议取共同的最高版本;
- 写消息时
msg.Code += offset,读消息时减回去,再投递到对应协议的通道; - 每 15 秒发一次 ping 保活;任何一个子协议的
Run返回,整条连接就会断开。
⑧ server.go:小区门卫室
Server 把上面所有部件组装起来。不论入站还是出站,连接都走同一个 SetupConn:
// p2p/server.go(节选)
remotePubkey, err := c.doEncHandshake(srv.PrivateKey) // ① RLPx 加密握手
...
err = srv.checkpoint(c, srv.checkpointPostHandshake) // ② 检查点:人满了吗?重复连接吗?是自己吗?
...
phs, err := c.doProtoHandshake(srv.ourHandshake) // ③ 协议握手:交换 caps
if id := c.node.ID(); !bytes.Equal(crypto.Keccak256(phs.ID), id[:]) {
return DiscUnexpectedIdentity
}
c.caps, c.name = phs.Caps, phs.Name
err = srv.checkpoint(c, srv.checkpointAddPeer) // ④ 检查点:有共同协议吗?然后启动 peer
类比:小区门卫室。 先查身份证(加密握手),再查访客名单和人数上限(检查点),然后问来干什么(协议握手),都通过了才登记入住。门卫室只有一个值班员(
run主循环)在改登记簿,其他人都通过 channel 递条子给他,所以不用到处加锁。
可信节点(--trustednodes)在人数已满时依然可以连进来。
另外,eth 服务还有一个 dropper(eth/dropper.go):每 3~7 分钟随机断开一个连接超过 10 分钟的 peer,让连接池保持流动、避免长期被固定的一批节点包围;对“交易打包贡献”排名前 10% 的 peer 则予以保护。
4.8 eth 协议与同步:合并之后的“物流调度”
先建立一个新认知:合并后,节点分成了两半
2022 年合并(The Merge)之后,一个完整的以太坊节点 = 共识层客户端(CL) + 执行层客户端(EL,即 Geth)。区块由 CL 在信标链网络上传播,CL 再通过 Engine API(8551 端口)把区块交给 Geth 执行。这彻底改变了 Geth 内部的同步方式:
| 过去(PoW) | 现在(v1.17.8) |
|---|---|
| Geth 自己挖矿、自己广播新区块 | CL 决定出块,Geth 只负责组装区块(miner.BuildPayload) |
| Geth 用总难度(TD)判断哪条链最长 | CL 告诉 Geth 链头在哪(engine_forkchoiceUpdated) |
eth 协议广播 NewBlock / NewBlockHashes | 这两个消息码还在,但已经没有处理函数,区块不再通过 eth 协议广播 |
| 区块 fetcher 负责接收新区块 | block_fetcher 已删除,只剩交易 fetcher |
| fast sync | snap sync(默认)和 full sync |
eth 服务:同时开两扇门
eth.Ethereum 是挂在 node 容器里的一个服务(eth/backend.go)。最值得记住的是:
func (s *Ethereum) APIs() []rpc.API // → 交给 rpc.Server:eth_ / txpool_ / debug_ / net_ ...(4.6)
func (s *Ethereum) Protocols() []p2p.Protocol // → 交给 p2p.Server:eth 和 snap 子协议(4.7)
// eth/backend.go:Protocols() 的实现
protos := eth.MakeProtocols((*ethHandler)(s.handler), s.networkID, s.discmix)
if s.config.SnapshotCache > 0 {
protos = append(protos, snap.MakeProtocols((*snapHandler)(s.handler), s.config.SnapV2)...)
}
RPC 走 APIs(),P2P 走 Protocols()。同一个 Ethereum 对象,一边通过 rpc 接待你(第三部分的所有方法),一边通过 p2p 和其他节点交换数据。
eth.New 的组装顺序:打开 chaindata 数据库 → 确定状态存储方案 → 加载链配置、创建共识引擎 → core.NewBlockChain → 日志索引 filtermaps → 交易池(legacypool + blobpool + 本地交易跟踪器)→ 网络 handler → dropper → miner → 给 RPC 用的 APIBackend 和 gas 价格预言机 → 注册 APIs、Protocols、生命周期。
eth 协议:版本、握手与消息
v1.17.8 支持 eth/69、eth/70、eth/71、eth/72(eth/68 已移除),单条消息最大 10 MB。
握手:双方互发 Status 消息,5 秒内必须完成:
// eth/protocols/eth/protocol.go:Status 消息的字段
ProtocolVersion uint32 // 协议版本
NetworkID uint64 // 网络 ID(主网 1)
Genesis common.Hash // 创世区块哈希
ForkID forkid.ID // 当前硬分叉指纹
EarliestBlock uint64 // 我保存的最早区块
LatestBlock uint64 // 我的最新区块
LatestBlockHash common.Hash
版本、网络 ID、创世块、ForkID 任何一个对不上就断开(这就是主网节点和测试网节点连不到一起的原因)。和老版本相比,总难度 TD 字段消失了(合并后不再用难度选链),换成了“我保存了哪段区块”的范围声明。eth/69 还新增了 BlockRangeUpdate 消息:大约每 32 个块广播一次自己的数据范围,方便对方知道该向谁要历史数据。
主要消息码:
| 消息码 | 消息 | 作用 |
|---|---|---|
| 0x00 | Status | 握手 |
| 0x02 | Transactions | 直接推送完整交易 |
| 0x03 / 0x04 | GetBlockHeaders / BlockHeaders | 要区块头 / 回区块头 |
| 0x05 / 0x06 | GetBlockBodies / BlockBodies | 要区块体 |
| 0x08 | NewPooledTransactionHashes | “我有这些新交易”(只发哈希) |
| 0x09 / 0x0a | GetPooledTransactions / PooledTransactions | 按哈希拉取交易 |
| 0x0f / 0x10 | GetReceipts / Receipts | 要收据 |
| 0x11 | BlockRangeUpdate | 声明自己的区块范围(eth/69) |
| 0x12 / 0x13 | GetBlockAccessLists / BlockAccessLists | 区块访问列表(eth/71) |
| 0x14 / 0x15 | GetCells / Cells | blob 数据分片(eth/72) |
收到消息后,handleMessage 按对方的协议版本选一张处理函数表,再按消息码查表,查不到就报“无效消息码”并断开。为了防止被“薅羊毛”,单次响应最多返回 1024 个区块头、区块体或收据,体积不超过 2 MB。
交易广播:√N 直发 + 其余只发哈希
你通过 eth_sendRawTransaction 提交的交易,进入交易池后由 BroadcastTransactions(eth/handler.go)传播出去:

// eth/handler.go:直发对象的个数
n := int(math.Ceil(math.Sqrt(float64(len(bc.tmp)))))
类比:新品发布会。 完整的样机只寄给约 √N 家核心媒体,其他媒体只收到“新品已发布,要样机请来函”的通知。大件(blob 交易、超过 4KB 的交易)一律只发通知。
选谁直发是“确定性的随机”:同一个发送者的交易总是发给同一批邻居,这样连续几笔交易能按 nonce 顺序到达对方,减少 nonce 空洞。
交易 fetcher:快递驿站的三格货架
收到哈希公告后,由 TxFetcher(eth/fetcher/tx_fetcher.go)决定什么时候、向谁拉取完整交易:

| 常量 | 值 | 含义 |
|---|---|---|
txArriveTimeout | 500ms | 公告后先等这么久,看对方会不会直接推送 |
txFetchTimeout | 5s | 拉取超时 |
maxTxAnnounces | 4096 | 每个邻居最多挂这么多未处理的公告(防 DoS) |
maxTxRetrievals | 256 | 一次最多拉 256 笔 |
maxTxRetrievalSize | 128KB | 一次最多拉 128KB |
被交易池以“价格太低”拒绝过的交易会记进一个 LRU 名单,5 分钟内不再重复拉取。
交易池:叫号大厅
core/txpool 是一个总池,下面挂两个子池:
| 子池 | 接收的交易 | 特点 |
|---|---|---|
legacypool | Legacy、AccessList(EIP-2930)、DynamicFee(EIP-1559)、SetCode(EIP-7702) | 常规交易,存内存 |
blobpool | Blob 交易(EIP-4844,type 3) | 体积大,存磁盘;每账户最多 16 笔;替换要求加价 100% |
一笔交易进池前要过两道检查:
- 无状态检查(
ValidateTransaction):交易类型是否支持、大小、对应硬分叉是否已激活、gas 不超过区块上限、maxFeePerGas ≥ maxPriorityFeePerGas、签名能恢复出发送者、intrinsic gas 够不够、小费不低于节点下限…… - 有状态检查(
ValidateTransactionWithState):nonce 不能比账户当前 nonce 小、余额够不够支付 gas 费 + 转账金额……
第三部分附录里那些报错,nonce too low、insufficient funds、replacement transaction underpriced,就是从这里返回的。
类比:银行叫号大厅。
- pending:号码连续、马上可以办理的交易(下一个就是你);
- queued:号码不连续、前面还缺人的交易(比如账户 nonce 是 5,你却发了 nonce 7 的交易)。等 nonce 6 到了,它才会被“提升”到 pending。
legacypool 的默认配置:每账户 16 个 pending 槽、全局 5120 个;每账户 64 个 queued、全局 1024 个;交易在池里最多停留 3 小时;同 nonce 替换至少加价 10%。
同步:先铺骨架,再挂车厢
![]()
谁来驱动? 不再是“找总难度最高的邻居”,而是 CL 通过 engine_forkchoiceUpdated 告诉 Geth“链头是 H”。本地没有这个区块,Geth 就启动 BeaconSync。
skeleton(骨架):既然链头是 CL 告诉我们的可信值,就从链头往回下载区块头,每个头只要验证“它的哈希等于下一个头的 parentHash”,一路链到本地已有的区块为止。这比从创世块往前下载更安全,因为终点是可信的。
类比:修铁路。 先从终点站(信标链告诉你的链头)往回铺骨架轨道,直到和本地已有的轨道接上;然后再把车厢(区块体、收据)一节节挂上去。
两种同步模式(--syncmode):
| 模式 | 做法 | 说明 |
|---|---|---|
snap(默认) | 下载区块头、区块体、收据;不执行历史交易;在 pivot 区块直接下载整份状态;pivot 之后的区块完整执行 | 几小时同步完主网 |
full | 从创世块开始逐块执行所有交易 | 最慢,但每一步都亲自验证 |
Geth 还会自动切换:本地已经有完整状态时,snap 自动切成 full;full 模式下发现状态缺失,又会切回 snap。
pivot 点:取链头往前 64 个块(fsMinFullBlocks = 64)。链头不断前进,pivot 过期后会往前挪。为什么不直接用链头?离链头太近,一旦发生重组,下载好的状态就可能作废。
snap 协议:按房间整箱搬家
老版本的 fast sync 是“按清单逐件搬”:沿着状态树一个节点一个节点地请求、校验,数以亿计的节点意味着数以亿计的网络往返。snap 协议改成了“按房间整箱搬”:
| 消息 | 作用 |
|---|---|
GetAccountRange / AccountRange | 按哈希区间批量拿账户(附带区间两端的默克尔证明) |
GetStorageRanges / StorageRanges | 按区间批量拿合约存储 |
GetByteCodes / ByteCodes | 拿合约代码 |
GetTrieNodes / TrieNodes | 拿个别树节点(用于修补) |
snap sync 的步骤:
- 把账户哈希空间切成 16 段并发下载,大合约的存储也切成 16 段;
- 每批数据都附带默克尔证明,收到后用 pivot 的
stateRoot校验; - 下载期间链头一直在前进,各段数据可能来自不同的状态版本,于是最后进入 healing(修补):用
GetTrieNodes找出不一致的树节点并补齐,直到整棵树和最新的stateRoot完全吻合。
类比:搬家。 不再拿清单一件件核对着搬,而是按房间成批装箱;最后拿清单检查一遍,补齐漏掉的、换掉过期的。
v1.17.8 还实现了实验性的 snap/2:借助区块访问列表(BAL)在本地“滚动追赶”状态,不再需要 healing。目前默认关闭,需要用
--snapv2打开。
出块:CL 导演,EL 演戏
合并后 Geth 的 miner 不再挖矿,只负责“组装区块”:
| Engine API | 作用 | 类比 |
|---|---|---|
engine_newPayloadVx | 把一个区块交给 Geth 执行、验证,但不改链头(InsertBlockWithoutSetHead) | “把这场戏排练一遍,看能不能演” |
engine_forkchoiceUpdatedVx | 设置链头、safe、finalized;如果带了出块参数,就开始组装新区块 | “就用这条片子定稿,顺便准备下一场” |
engine_getPayloadVx | 取走组装好的区块 | “把准备好的下一场交上来” |
BuildPayload 的策略很实在:先立刻生成一个空块,保证这个 slot 至少有东西可交;然后在后台每 2 秒重新挑选交易、生成一个更好的区块;CL 来取的时候,交出当前最好的版本。
4.9 filter 与日志索引:eth_getLogs 为什么能这么快
它解决什么问题
eth_getLogs 要在几百万个区块里找出“某合约、某主题”的日志。逐块读收据显然太慢。v1.17.8 用两层机制配合:新的日志索引 filtermaps 负责已经建好索引的区块范围;区块头里的布隆过滤器负责还没建索引的最新区块。
很多老资料讲的是
core/bloombits(每 4096 个区块把 bloom 位“旋转”存储)。这套机制在新版中已经被 filtermaps 取代,core/bloombits目录已经删除,数据库里只保留了清理旧数据的代码。
第一层:区块头里的布隆过滤器(仍在计算)

- 一条日志 = (合约地址,若干 topic,data);
- 布隆过滤器是 2048 位(256 字节) 的位图。对日志的地址和每个 topic(data 不参与)做 Keccak256,取哈希的前 6 个字节,每 2 个字节取低 11 位(0~2047),得到 3 个位置,把这 3 位置为 1:
// core/types/bloom9.go(bloomValues 节选)
sha.Write(data)
sha.Read(hashbuf[:])
v1 := byte(1 << (hashbuf[1] & 0x7))
v2 := byte(1 << (hashbuf[3] & 0x7))
v3 := byte(1 << (hashbuf[5] & 0x7))
i1 := BloomByteLength - uint((binary.BigEndian.Uint16(hashbuf[0:])&0x7ff)>>3) - 1
i2 := BloomByteLength - uint((binary.BigEndian.Uint16(hashbuf[2:])&0x7ff)>>3) - 1
i3 := BloomByteLength - uint((binary.BigEndian.Uint16(hashbuf[4:])&0x7ff)>>3) - 1
- 一笔交易收据的 bloom = 它所有日志 bloom 的按位或;区块头的
logsBloom= 所有收据 bloom 的按位或(types.MergeBloom)。
类比:书封面上的关键词贴纸。 贴纸上没有某个词,书里一定没有;贴纸上有,书里不一定有(可能是别的词碰巧贴在同一个位置),还得翻开确认。布隆过滤器只会误报,不会漏报。
它的局限也很明显:每个区块只有 2048 位,日志一多就几乎全是 1,误报率很高;而且查询仍然要逐块读区块头。
第二层:filtermaps——给所有日志排一张“座位表”
core/filtermaps 实现了 EIP-7745 提出的日志索引数据结构(Geth 只在本地使用它,不涉及共识改动)。思路分四步:
① 把所有日志拉成一条长队,每个值一个编号
区块 N:[分隔符] [日志1.地址] [日志1.topic0] [日志1.topic1] [日志2.地址] [日志2.topic0] ...
区块 N+1:[分隔符] ...
↑ 每个位置都有一个全局递增的编号:log value index
每个区块先占一个“分隔符”位置,每条日志占 1 + topic 数量 个位置(地址一个、每个 topic 一个)。
② 每 65536 个值组成一张 map,每 1024 张 map 组成一个 epoch
// core/filtermaps/math.go
var DefaultParams = Params{
logMapHeight: 16, // 每张 map 有 2^16 = 65536 行
logMapWidth: 24, // 列空间 2^24
logMapsPerEpoch: 10, // 每个 epoch 有 2^10 = 1024 张 map
logValuesPerMap: 16, // 每张 map 装 2^16 = 65536 个日志值
baseRowGroupSize: 32,
baseRowLengthRatio: 8,
logLayerDiff: 4,
}
(行尾的中文注释是笔者按参数含义补充的,源码原文没有。)
③ 每个值按哈希落到某一行、某一列
- 行号:由
SHA-256(值 ‖ map 编号的高位 ‖ 层号)算出。同一个地址在同一个 epoch 内总是落在同一行,查询时就能连续读取磁盘; - 列号:高位是这个值在 map 内的位置,低位是几位校验哈希。匹配时既能反推出它的全局编号(从而找到区块和日志),又能用校验位过滤掉大部分误报;
- 某一行被热门地址(比如 USDT 合约)塞满后,溢出的值会写到“更高一层”的另一行,避免一行无限变长。
④ 查询时只读相关的几行
查询“某地址的日志”,只要算出它在每张 map 里对应的行,读出这些行,就能直接得到候选日志的编号,再去收据里精确比对。
类比:bloom 是“每本书封面上的指纹贴纸”,要一本本翻;filtermaps 是“按日志条目编号建的二维座位表”,直接告诉你哪几个座位可能坐着你要找的人。
注意:filtermaps 计算行号用的是 SHA-256,而区块头 bloom 用的是 Keccak256,两者不要混淆。
索引默认覆盖最近 235 万个区块(--history.logs,约一年),数据存在 fm- 前缀下;可以用 --history.logs.disable 关闭。
查询流程:eth_getLogs 背后

// eth/filters/filter.go:未索引部分逐块预筛
func (f *Filter) blockLogs(ctx context.Context, header *types.Header) ([]*types.Log, error) {
if bloomFilter(header.Bloom, f.addresses, f.topics) {
return f.checkMatches(ctx, header)
}
return nil, nil
}
- 精确比对时用的就是第三部分讲过的 topics 规则:同一位置内是“或”,不同位置之间是“与”,空数组表示任意;
- 如果地址和 topic 全是通配(等于“给我所有日志”),索引帮不上忙,会直接回退到逐块查询;
- 搜索过程中如果发生了链重组,会重新校验结果所在的区块范围。
两种“订阅”方式:轮询 vs 推送
FilterAPI(eth/filters/api.go)同时提供两种模式,正好对应第三部分 3.11 节的两组方法:
| 模式 | 方法 | 原理 | 适合 |
|---|---|---|---|
| 轮询 | eth_newFilter / eth_newBlockFilter / eth_newPendingTransactionFilter + eth_getFilterChanges | 服务端登记一个 filter,把新数据攒在里面,客户端每次来取走并清空;每取一次就续期,5 分钟不来取就自动删除 | HTTP |
| 推送 | eth_subscribe("logs" / "newHeads" / "newPendingTransactions" / "transactionReceipts") | 通过 4.6 讲的 Notifier 直接推送,连接断开即结束 | WebSocket / IPC |
两种模式的数据都来自 EventSystem:
// eth/filters/filter_system.go(节选)
type EventSystem struct {
backend Backend
sys *FilterSystem
txsSub event.Subscription // Subscription for new transaction event
logsSub event.Subscription // Subscription for new log event
rmLogsSub event.Subscription // Subscription for removed log event
chainSub event.Subscription // Subscription for new chain event
install chan *subscription // install filter for event notification
uninstall chan *subscription // remove filter for event notification
...
}
区块链和交易池通过进程内的 event.Feed 发出“新区块”“新日志”“被重组移除的日志”“新交易”事件,EventSystem 再分发给各个 filter 或订阅。
第三部分的订阅示例,背后就是这条链路:
BlockChain插入新区块 →event.Feed→EventSystem→FilterAPI.NewHeads里的notifier.Notify→ WebSocket → 你的chan *types.Header。注意
rmLogsSub:发生链重组时,被撤销区块里的日志会以removed: true的形式再推送一次。监听事件做业务时,记得处理这种情况。
4.10 EVM:执行合约的“投币计算器”
它解决什么问题
全世界的节点要执行同一段合约字节码,并得到完全相同的结果;同时要按计算量收费(gas),防止死循环拖垮网络。
类比:一台投币式计算器。
- Stack(栈):一摞最多 1024 张的草稿纸,每张写一个 256 位的数,只能在最上面写、从最上面拿;
- Memory(内存):一块按 32 字节一格租用的白板,用得越多,单价越贵;
- Storage(存储):写进账本的永久记录,也就是 4.4 的存储树,最贵;
- JumpTable(指令表):一本 256 页的说明书,每页写着一条指令“做什么、基础收费多少、额外收费怎么算、需要几张草稿纸、要占多大白板”;
- Gas:投进去的硬币,用完计算器就停。
核心结构
栈:元素是定长的 uint256.Int。比老版本的 *big.Int 快得多,也不再需要对象池来复用大整数。
// core/vm/stack.go(节选)
type stackArena struct {
data []uint256.Int
top int
}
type Stack struct {
bottom int
size int
inner *stackArena
}
一笔交易里,嵌套调用的各层栈共享同一块 arena,每层从 bottom 开始切出自己的区间,返回时把空间还回去;arena 本身再通过 sync.Pool 复用。
内存:Memory{store []byte, lastGasCost uint64}。扩容费 = 字数的线性部分 + 平方部分(words²/512),lastGasCost 记住已经付过多少,每次只收差额。平方项让“占用超大内存”的成本迅速变得不可接受。
指令表:
// core/vm/jump_table.go
type operation struct {
// execute is the operation function
execute executionFunc
constantGas uint64
dynamicGas gasFunc
// minStack tells how many stack items are required
minStack int
// maxStack specifies the max length the stack can have for this operation
// to not overflow the stack.
maxStack int
// memorySize returns the memory size required for the operation
memorySize memorySizeFunc
// undefined denotes if the instruction is not officially defined in the jump table
undefined bool
}
type JumpTable [256]*operation // 一个字节的操作码正好 256 种
constantGas:固定收费(比如ADD是 3);dynamicGas:和参数相关的额外收费(比如SSTORE取决于槽位的新旧值,CALL取决于是否转账、目标账户是否“冷”);minStack/maxStack:执行前栈里至少要有几个元素、最多能有几个(maxStack = 1024 + 弹出数 − 压入数)。
指令表按硬分叉逐级叠加,每一级都是“复制上一级再打补丁”:
Frontier → Homestead → TangerineWhistle → SpuriousDragon → Byzantium → Constantinople
→ Istanbul → Berlin → London → Merge → Shanghai → Cancun → Prague → Osaka → Amsterdam → Bogota
例如 Cancun 加入了 BLOBHASH、BLOBBASEFEE、TLOAD/TSTORE(瞬态存储)、MCOPY;Osaka 加入了 CLZ。EVM 根据当前区块所处的分叉选择对应的指令表,所以同一段字节码在升级前后的行为可能不同。
截至 v1.17.8,主网只激活到 Osaka。Amsterdam、Bogota 的指令表是为未来分叉提前写好的代码。
解释器主循环:取指 → 检查 → 计费 → 执行
新版中,解释器已经并入 EVM 本身,入口是 func (evm *EVM) Run(contract *Contract, input []byte, readOnly bool):
// core/vm/interpreter.go(主循环节选)
for {
op = contract.GetOp(pc)
operation := jumpTable[op]
if sLen := stack.len(); sLen < operation.minStack {
return nil, &ErrStackUnderflow{stackLen: sLen, required: operation.minStack}
} else if sLen > operation.maxStack {
return nil, &ErrStackOverflow{stackLen: sLen, limit: operation.maxStack}
}
if !contract.Gas.ChargeExecutionOnly(execCost) { // 先收固定费用
return nil, ErrOutOfGas
}
if operation.dynamicGas != nil {
// 算出需要的内存大小(按 32 字节取整)→ 计算并扣除动态费用
}
if memorySize > 0 {
mem.Resize(memorySize)
}
res, err = operation.execute(&pc, evm, callContext) // 执行指令
if err != nil {
break
}
pc++
}

注意两点:
- 先计费、后执行:钱不够的指令根本不会执行;
- REVERT 和其他错误的待遇不同。源码注释原话是:解释器返回的任何错误都应视为“回滚并耗尽所有 gas”,ErrExecutionReverted 除外,它表示“回滚但保留剩余 gas”。
// core/vm/gascosts.go:一层调用结束时如何结算 gas
func (g GasBudget) Exit(err error) GasBudget {
switch {
case err == nil:
return g.ExitSuccess()
case err == ErrExecutionReverted:
return g.ExitRevert() // 剩余 gas 退还给调用者
default:
return g.ExitHalt() // 剩余执行 gas 清零
}
}
GasBudget是 v1.17.8 为 Amsterdam 的 EIP-8037 准备的二维 gas:把“执行 gas”和“状态 gas”(写入新状态的成本)分开计量。目前主网仍按单一 gas 计算,你可以先把它理解成“剩余 gas”。和 JSON-RPC 的联系:
eth_call返回的execution reverted(错误码 3),就是解释器返回了ErrExecutionReverted,data字段里带着 revert 原因;eth_estimateGas用二分查找反复调用 EVM,找出不会触发ErrOutOfGas的最小 gas;debug_traceTransaction默认 tracer 输出的每一行(pc、op、gas、stack、memory),就是这个循环每转一圈时的状态快照。
Contract 与 EVM:Call、Create 和三个变体
// core/vm/contract.go(删去了字段注释)
type Contract struct {
caller common.Address
address common.Address
jumpDests JumpDestCache
analysis BitVec
Code []byte
CodeHash common.Hash
Input []byte
IsDeployment bool
IsSystemCall bool
Gas GasBudget
value *uint256.Int
}
Contract 是“正在执行的那一份代码”:谁调用的、自己的地址、代码、输入数据、剩余 gas、转账金额。EVM 则持有区块上下文(COINBASE、NUMBER、TIMESTAMP 这类指令读的就是它)和 StateDB。
Call(调用合约),对应第三部分的“调用合约写方法”:
- 调用深度超过 1024 →
ErrDepth;余额不足 → 报错; - 打快照
StateDB.Snapshot(); - 目标是预编译合约(如
ecrecover、sha256)→ 直接运行 Go 实现的预编译代码; - 目标账户不存在、又不是预编译、并且没有转账 → 什么都不做,直接返回(EIP-158);
- 转账;加载目标地址的代码,
evm.Run执行; - 出错就
RevertToSnapshot,只回滚这一层的修改。
Create(部署合约),对应第三部分的“部署合约”:
- 深度、余额、nonce 预检查;发送者 nonce + 1;
- 计算新地址:
CREATE用CreateAddress(部署者, nonce),CREATE2用CreateAddress2(部署者, salt, keccak256(initcode))。所以部署前就能算出合约地址; - 地址冲突检查 → 打快照 → 创建账户(新合约的 nonce 为 1)→ 转账;
- 执行初始化代码(构造函数),它的返回值才是合约的运行时代码;
- 检查运行时代码:不能以
0xEF开头(EIP-3541)、不能超过 24576 字节;按代码长度 × 200 gas扣“存代码费”,然后写入; - 任何一步出错就回滚到快照。
这解释了 3.3 节的一个现象:
eth_getCode拿到的运行时代码,比部署时发送的字节码短,因为构造函数只在部署时执行一次,不会存到链上。
三个变体(只能由合约里的操作码触发):
| 指令 | 用谁的存储 | msg.sender / msg.value | 典型用途 |
|---|---|---|---|
CALL | 被调用合约自己的 | 当前合约 / 传入的值 | 普通跨合约调用 |
DELEGATECALL | 调用者自己的 | 保持不变(原始调用者 / 原始金额) | 代理合约、可升级合约 |
STATICCALL | 被调用合约的 | 当前合约 / 0 | 只读调用:以 readOnly=true 运行,任何写操作都报 ErrWriteProtection |
CALLCODE | 调用者自己的 | 当前合约 | 已被 DELEGATECALL 取代 |
63/64 规则:发起子调用时,最多只能把剩余 gas 的 63/64 交给子调用(core/vm/gas.go 的 callGas:gas := availableGas - availableGas/64),自己至少留下 1/64。这样即使调用嵌套很深、子调用耗尽了 gas,父调用也还有余量处理失败。
一笔交易是怎么被执行的

- intrinsic gas:交易还没执行就要先付的“门票”:基础费 21000(创建合约是 53000),加上 calldata 费(零字节 4、非零字节 16)、访问列表费、initcode 按字计费等。
gasLimit连门票都不够,就报intrinsic gas too low; - 退款上限:清空存储槽等操作会产生 gas 退款,但伦敦升级(EIP-3529)之后,退款最多只能抵扣已用 gas 的 1/5(之前是 1/2):
// core/state_transition.go
func (st *stateTransition) calcRefund(gasUsedBeforeRefund uint64) uint64 {
quotient := params.RefundQuotient
if st.evm.ChainConfig().IsLondon(st.evm.Context.BlockNumber) {
quotient = params.RefundQuotientEIP3529
}
refund := gasUsedBeforeRefund / quotient
if refund > st.state.GetRefund() {
refund = st.state.GetRefund()
}
return refund
}
eth_call和eth_estimateGas走的也是这条路:DoCall→core.ApplyMessage→evm.Call,只是在一份状态副本上执行,结束后直接丢弃,所以不会真的改变链上状态。默认最多给 5000 万 gas、执行 5 秒(--rpc.gascap、--rpc.evmtimeout)。
4.11 串起来:一笔交易在 Geth 里的完整旅程
学完所有模块,我们跟着第三部分 3.10 节(eth_sendRawTransaction)那笔 ETH 转账,走完它的一生:

- 你在本地签名,通过
eth_sendRawTransaction把RLP 编码的交易交给节点; rpc.Server切出eth+sendRawTransaction,反射调用TransactionAPI.SendRawTransaction(4.6);- 交易解码、验签,通过交易池的两道检查后进入 pending,返回交易哈希(4.8);
- eth 协议把交易发给 √N 个邻居,其余邻居只收到哈希,再由它们的 TxFetcher 来拉取(4.7、4.8);
- 轮到某个验证者出块时,它的共识层客户端通过 Engine API 让 Geth 组装区块:EVM 逐笔执行交易,修改 StateDB,产生收据和日志(4.10);
- 区块经共识层广播;每个节点的 Geth 收到
engine_newPayload后重新执行、验证,算出相同的stateRoot,把改动写入 pathdb 和数据库(4.3、4.4、4.5); engine_forkchoiceUpdated把新区块设为链头,事件系统通知所有订阅者,日志进入索引(4.9);- 你调用
eth_getTransactionReceipt,看到status = 1。
你在第三部分调用的每一个 JSON-RPC 方法,都对应着这条旅程中的某一站。 这也是学习区块链后端开发最好的路线:先会调用,再顺着调用一层层往里读源码。
附录 A:常见报错速查
| 报错 | 原因 | 对应知识点 |
|---|---|---|
-32601 the method xxx does not exist/is not available | 方法不存在(如在 Geth 上调用 trace_、erigon_),或者命名空间没开放(如没加 --http.api debug) | 1.4、2.4、4.6 |
-32602 invalid argument | 参数格式错误:十六进制带前导零、地址长度不对、参数个数不对等 | 1.4、4.6 |
-32600 batch too large | 批量请求超过节点限制(Geth 默认 1000 条) | 4.6 |
-32002 request timed out | 方法执行超时(如范围太大的 eth_getLogs、复杂的 trace) | 4.6 |
3 execution reverted | 合约执行了 revert / require 失败,data 中带有原因 | 3.3、4.10 |
nonce too low | 这个 nonce 已经被使用过 | 3.3、4.8 |
replacement transaction underpriced | 用相同 nonce 替换交易时,手续费加价不足 10% | 3.2、4.8 |
insufficient funds for gas * price + value | 余额不够支付 gas 费加转账金额 | 3.3、4.8 |
intrinsic gas too low | gasLimit 连交易的基础门票都不够 | 4.10 |
missing trie node | 节点不再保存该区块的历史状态,需要归档节点 | 4.4 |
notifications not supported | 用 HTTP 连接调用了订阅方法 | 1.2、4.6 |
ethereum.NotFound(not found) | 交易还没被打包,或者查询的对象不存在 | 3.5 |

