Ethereum JSON-RPC 和 Go-Ethereum:从“会调用”到“看懂源码”

适合谁读:刚开始学区块链开发、会一点 Go 的同学(第一、二、三部分),以及想弄清 Geth 内部怎么运转的进阶同学(第四部分)。

阅读路线:

  1. 是什么:JSON-RPC 是怎样一门“和节点对话的语言”
  2. 谁来说:Geth 如何实现这门语言,ethclient 又是怎么替我们“翻译”的
  3. 怎么用:以 ethereum.org 官方 JSON-RPC 文档 为基准,合并 ethereum-json-rpc.com 和 execution-apis 规范,把 83 个方法逐个讲清楚。每个方法都配一段可以直接运行的 Go 示例,并穿插查区块、转账、部署合约、订阅事件等常见实战场景的核心代码
  4. 为什么能用:钻进 Geth 源码,看一个请求进门之后,经过 RPC、EVM、状态树、数据库、P2P 网络的整个旅程

版本说明:本文所有 Go 示例和源码解析都基于 go-ethereum v1.17.8(撰写时的最新版本),示例中用到的 ethclient / gethclient 接口均已在该版本下编译验证。示例数据统一使用 Sepolia 测试网(区块 5671744、一个简单的 Store 合约等),方便你直接复制运行。


目录


第一部分:什么是 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 描述“我要调哪个函数、传什么参数”的远程调用协议。它本身不规定传输方式,以太坊节点一般同时支持三种通道:

通道默认地址特点典型场景
HTTPhttp://127.0.0.1:8545一问一答,无状态查询、发交易
WebSocketws://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 的申请单

客户端、ethclient 与 Geth 节点的分层关系

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/gethclientGeth 特有扩展: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 测试网地址:

服务商说明
Alchemy / Infura / QuickNode注册后获得 https://... 和 wss://... 两种地址
PublicNode免注册,直接可用,偶尔不稳定

发交易需要测试 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 调用方式一句话说明
获取链 IDeth_chainIdclient.ChainID(ctx)(await provider.getNetwork()).chainId当前链的 ID(主网 1,Sepolia 11155111)
获取最新区块号eth_blockNumberclient.BlockNumber(ctx)provider.getBlockNumber()最新区块号
获取建议 gas 价格eth_gasPriceclient.SuggestGasPrice(ctx)(await provider.getFeeData()).gasPrice建议的 gas 价格
获取建议小费eth_maxPriorityFeePerGasclient.SuggestGasTipCap(ctx)(await provider.getFeeData()).maxPriorityFeePerGas建议的小费(EIP-1559)
获取 blob 基础费eth_blobBaseFeeclient.BlobBaseFee(ctx)provider.send("eth_blobBaseFee", [])当前 blob 基础费(EIP-4844)
获取费用历史eth_feeHistoryclient.FeeHistory(ctx, 5, nil, []float64{25, 50, 75})provider.send("eth_feeHistory", ["0x5", "latest", [25, 50, 75]])最近若干区块的费用历史

账户与状态(3.3)

操作功能JSON-RPC 方法Go 调用方式TS 调用方式一句话说明
获取余额eth_getBalanceclient.BalanceAt(ctx, addr, blockNum)provider.getBalance(address, blockTag)账户 ETH 余额
获取合约代码eth_getCodeclient.CodeAt(ctx, addr, blockNum)provider.getCode(address, blockTag)合约字节码
读取存储槽eth_getStorageAtclient.StorageAt(ctx, addr, slot, blockNum)provider.getStorage(address, slot, blockTag)合约某个存储槽的原始值
只读调用合约eth_callclient.CallContract(ctx, msg, blockNum)provider.call(tx) 或 contract.balanceOf(addr)只读调用合约,不上链
估算 Gas 消耗eth_estimateGasclient.EstimateGas(ctx, msg)provider.estimateGas(tx)估算交易需要多少 gas
查询事件日志eth_getLogsclient.FilterLogs(ctx, query)provider.getLogs(filter) 或 contract.queryFilter(event)按条件查询事件日志
获取默克尔证明eth_getProofgeth.GetProof(ctx, addr, keys, blockNum)provider.send("eth_getProof", [address, ["0x0"], "latest"])账户/存储的默克尔证明
获取 nonceeth_getTransactionCountclient.PendingNonceAt(ctx, addr) 或 client.NonceAt(ctx, addr, blockNum)provider.getTransactionCount(address, "pending")账户的 nonce

区块(3.4)

操作功能JSON-RPC 方法Go 调用方式TS 调用方式一句话说明
按区块号查区块eth_getBlockByNumberclient.BlockByNumber(ctx, num) 或 client.HeaderByNumber(ctx, num)provider.getBlock(blockNumber, prefetchTxs)按区块号查区块
按区块哈希查区块eth_getBlockByHashclient.BlockByHash(ctx, hash) 或 client.HeaderByHash(ctx, hash)provider.getBlock(blockHash, prefetchTxs)按区块哈希查区块
按区块号统计交易数eth_getBlockTransactionCountByNumberrpcClient.CallContext(ctx, &result, "eth_getBlockTransactionCountByNumber", blockNum)(pending 用 client.PendingTransactionCount(ctx))(await provider.getBlock(num)).transactions.length按区块号查交易数
按区块哈希统计交易数eth_getBlockTransactionCountByHashclient.TransactionCount(ctx, blockHash)(await provider.getBlock(hash)).transactions.length按区块哈希查交易数
按区块号统计叔块数eth_getUncleCountByBlockNumberrpcClient.CallContext(ctx, &result, "eth_getUncleCountByBlockNumber", blockNum)provider.send("eth_getUncleCountByBlockNumber", [blockNum])按区块号查叔块数
按区块哈希统计叔块数eth_getUncleCountByBlockHashrpcClient.CallContext(ctx, &result, "eth_getUncleCountByBlockHash", blockHash)provider.send("eth_getUncleCountByBlockHash", [blockHash])按区块哈希查叔块数

交易与收据(3.5)

操作功能JSON-RPC 方法Go 调用方式TS 调用方式一句话说明
按哈希查交易eth_getTransactionByHashclient.TransactionByHash(ctx, txHash)provider.getTransaction(hash)按交易哈希查交易
按区块号和下标查交易eth_getTransactionByBlockNumberAndIndexrpcClient.CallContext(ctx, &result, "eth_getTransactionByBlockNumberAndIndex", blockNum, index)(await provider.getBlock(num)).getTransaction(i)按(区块号, 下标)查交易
按区块哈希和下标查交易eth_getTransactionByBlockHashAndIndexclient.TransactionInBlock(ctx, blockHash, i)(await provider.getBlock(hash)).getTransaction(i)按(区块哈希, 下标)查交易
获取交易收据eth_getTransactionReceiptclient.TransactionReceipt(ctx, txHash)provider.getTransactionReceipt(hash)交易收据(成功与否、日志、gas)
按区块号和下标查叔块eth_getUncleByBlockNumberAndIndexrpcClient.CallContext(ctx, &result, "eth_getUncleByBlockNumberAndIndex", blockNum, index)provider.send("eth_getUncleByBlockNumberAndIndex", [blockNum, index])按(区块号, 下标)查叔块
按区块哈希和下标查叔块eth_getUncleByBlockHashAndIndexrpcClient.CallContext(ctx, &result, "eth_getUncleByBlockHashAndIndex", blockHash, index)provider.send("eth_getUncleByBlockHashAndIndex", [blockHash, index])按(区块哈希, 下标)查叔块

节点状态(3.6)

操作功能JSON-RPC 方法Go 调用方式TS 调用方式一句话说明
获取节点托管账户eth_accountsrpcClient.CallContext(ctx, &result, "eth_accounts")provider.listAccounts()节点托管的账户列表
查询同步进度eth_syncingclient.SyncProgress(ctx)provider.send("eth_syncing", [])节点同步进度

debug_ 交易追踪(3.7)

操作功能JSON-RPC 方法Go 调用方式TS 调用方式一句话说明
模拟调用并追踪debug_traceCallgeth.TraceCallWithCallTracer(ctx, msg, block, nil, nil, cfg)provider.send("debug_traceCall", [tx, "latest", { tracer: "callTracer" }])模拟一次调用并输出执行轨迹
追踪已上链交易debug_traceTransactiongeth.TraceTransactionWithCallTracer(ctx, txHash, cfg)provider.send("debug_traceTransaction", [txHash, { tracer: "callTracer" }])重放一笔已上链交易并输出轨迹
按区块号追踪整块debug_traceBlockByNumberrpcClient.CallContext(ctx, &result, "debug_traceBlockByNumber", blockNum, opts)provider.send("debug_traceBlockByNumber", [blockNum, { tracer: "callTracer" }])按区块号重放整块所有交易
按区块哈希追踪整块debug_traceBlockByHashgeth.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_sendRawTransactionclient.SendTransaction(ctx, signedTx)provider.broadcastTransaction(signedTx) 或 wallet.sendTransaction(tx)广播已签名交易(所有写操作的出口)
发送并等待收据eth_sendRawTransactionSyncclient.SendTransactionSync(ctx, signedTx, &timeout)provider.send("eth_sendRawTransactionSync", [signedTx, 30000])发送并等待上链,直接返回收据(EIP-7966)
节点签名并发送eth_sendTransactionrpcClient.CallContext(ctx, &result, "eth_sendTransaction", txArgs)(需节点托管账户)(await provider.getSigner()).sendTransaction(tx)让节点用托管的私钥签名并发送
节点签名消息eth_signrpcClient.CallContext(ctx, &result, "eth_sign", addr, data)(需节点托管账户)provider.send("eth_sign", [address, data])让节点对消息签名(EIP-191)
节点签名交易eth_signTransactionrpcClient.CallContext(ctx, &result, "eth_signTransaction", txArgs)(需节点托管账户)(await provider.getSigner()).signTransaction(tx)让节点签名交易但不发送
节点补全交易字段eth_fillTransactionrpcClient.CallContext(ctx, &result, "eth_fillTransaction", txArgs)provider.send("eth_fillTransaction", [tx])让节点补全 nonce、gas、费用等字段

过滤器与订阅(3.11)

操作功能JSON-RPC 方法Go 调用方式TS 调用方式一句话说明
创建日志过滤器eth_newFilterrpcClient.CallContext(ctx, &result, "eth_newFilter", filter)provider.send("eth_newFilter", [filter])登记日志过滤器
创建新区块过滤器eth_newBlockFilterrpcClient.CallContext(ctx, &result, "eth_newBlockFilter")provider.send("eth_newBlockFilter", [])登记新区块过滤器
创建新交易过滤器eth_newPendingTransactionFilterrpcClient.CallContext(ctx, &result, "eth_newPendingTransactionFilter")provider.send("eth_newPendingTransactionFilter", [])登记新交易过滤器
轮询过滤器增量eth_getFilterChangesrpcClient.CallContext(ctx, &result, "eth_getFilterChanges", filterID)provider.send("eth_getFilterChanges", [filterId])取过滤器上次以来的新数据(增量)
获取过滤器全部日志eth_getFilterLogsrpcClient.CallContext(ctx, &result, "eth_getFilterLogs", filterID)provider.send("eth_getFilterLogs", [filterId])取日志过滤器条件下的全部日志(全量)
删除过滤器eth_uninstallFilterrpcClient.CallContext(ctx, &result, "eth_uninstallFilter", filterID)provider.send("eth_uninstallFilter", [filterId])注销过滤器
订阅推送eth_subscribewsClient.SubscribeNewHead(ctx, ch) 或 wsClient.SubscribeFilterLogs(ctx, q, ch)wsProvider.on("block", cb) 或 contract.on("ItemSet", cb)WebSocket 订阅新区块、日志、交易等
取消订阅eth_unsubscribesub.Unsubscribe()wsProvider.off("block", cb)取消订阅

节点与客户端信息(3.12)

操作功能JSON-RPC 方法Go 调用方式TS 调用方式一句话说明
获取客户端版本web3_clientVersionrpcClient.CallContext(ctx, &result, "web3_clientVersion")provider.send("web3_clientVersion", [])节点软件名称与版本
计算 Keccak-256web3_sha3rpcClient.CallContext(ctx, &result, "web3_sha3", data)(本地用 crypto.Keccak256)provider.send("web3_sha3", [data])(本地用 ethers.keccak256)计算 Keccak-256
获取网络 IDnet_versionclient.NetworkID(ctx)provider.send("net_version", [])网络 ID
查询 P2P 监听状态net_listeningrpcClient.CallContext(ctx, &result, "net_listening")provider.send("net_listening", [])是否在监听 P2P 连接
获取对等节点数net_peerCountclient.PeerCount(ctx)provider.send("net_peerCount", [])已连接的对等节点数
获取硬分叉配置eth_configrpcClient.CallContext(ctx, &result, "eth_config")provider.send("eth_config", [])当前与下一个硬分叉的配置(EIP-7910)
查询节点历史数据范围eth_capabilitiesrpcClient.CallContext(ctx, &result, "eth_capabilities")provider.send("eth_capabilities", [])节点能提供哪些历史数据

状态与执行增强(3.13)

操作功能JSON-RPC 方法Go 调用方式TS 调用方式一句话说明
获取整块收据eth_getBlockReceiptsclient.BlockReceipts(ctx, blockNrOrHash)provider.send("eth_getBlockReceipts", [blockTag])一次取整块的所有收据
批量读取存储槽eth_getStorageValuesrpcClient.CallContext(ctx, &result, "eth_getStorageValues", requests, "latest")provider.send("eth_getStorageValues", [{ [address]: [slot] }, "latest"])批量读多个合约的多个存储槽
获取下一块 baseFeeeth_baseFeerpcClient.CallContext(ctx, &result, "eth_baseFee")provider.send("eth_baseFee", [])下一个区块的 baseFee
生成访问列表eth_createAccessListgeth.CreateAccessList(ctx, msg)provider.send("eth_createAccessList", [tx, "latest"])生成 EIP-2930 访问列表
多笔调用模拟执行eth_simulateV1client.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_getRawHeaderrpcClient.CallContext(ctx, &result, "debug_getRawHeader", blockNum)provider.send("debug_getRawHeader", [blockTag])区块头的 RLP 原始编码
获取区块原始编码debug_getRawBlockrpcClient.CallContext(ctx, &result, "debug_getRawBlock", blockNum)provider.send("debug_getRawBlock", [blockTag])整个区块的 RLP 原始编码
获取交易原始编码debug_getRawTransactionrpcClient.CallContext(ctx, &result, "debug_getRawTransaction", txHash)provider.send("debug_getRawTransaction", [txHash])交易的原始编码
获取收据原始编码debug_getRawReceiptsrpcClient.CallContext(ctx, &result, "debug_getRawReceipts", blockNum)provider.send("debug_getRawReceipts", [blockTag])区块所有收据的原始编码
查看坏区块debug_getBadBlocksrpcClient.CallContext(ctx, &result, "debug_getBadBlocks")provider.send("debug_getBadBlocks", [])节点最近拒收的坏区块
获取区块访问列表原始编码debug_getRawBlockAccessList不支持provider.send("debug_getRawBlockAccessList", [blockTag])区块访问列表的原始编码
查看交易池数量txpool_statusrpcClient.CallContext(ctx, &result, "txpool_status")provider.send("txpool_status", [])交易池中 pending / queued 的数量
查看交易池全部交易txpool_contentrpcClient.CallContext(ctx, &result, "txpool_content")provider.send("txpool_content", [])交易池全部交易
查看某地址的待处理交易txpool_contentFromrpcClient.CallContext(ctx, &result, "txpool_contentFrom", addr)provider.send("txpool_contentFrom", [address])交易池中某地址的交易
查看交易池摘要txpool_inspectrpcClient.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 —— 最近几个区块的“油价走势图”

参数类型说明
blockCountQUANTITY要看最近多少个区块(最多 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 —— 查余额

参数类型说明
addressDATA, 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 丢精度。例如 25729324269165216042 wei 显示为 25.729324269165216042 ETH。

常见误区:普通全节点只保留最近约 128 个区块的完整状态,查很久以前的余额会报 missing trie node,需要归档节点(archive node)。这和第四部分讲的状态树(Trie)存储方式直接相关。

8. eth_getCode —— 这个地址是合约吗?

参数类型说明
addressDATA, 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,直接读某一格的原始数据。

参数类型说明
addressDATA, 20 字节合约地址
positionQUANTITY / 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 调用 getter items(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 二选一)
blockHashDATA, 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 都靠它。

参数类型说明
addressDATA, 20 字节账户地址
storageKeysDATA 数组要证明的存储槽
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 是账户“已经发出多少笔交易”的计数器,也是下一笔交易必须使用的序号。它保证交易按顺序执行、不能被重放。

参数类型说明
addressDATA, 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"
fullTxBooleantrue 返回完整交易对象;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 —— 按哈希查区块

参数类型说明
blockHashDATA, 32 字节区块哈希
fullTxBoolean同上
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区块号/标签区块
indexQUANTITY交易在区块中的下标,从 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 笔交易

参数类型说明
blockHashDATA, 32 字节区块哈希
indexQUANTITY下标
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 字节
返回字段说明
status1 成功,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 字节包含叔块的区块
indexQUANTITY叔块下标
返回叔块的区块头(叔块没有交易),没有则返回 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 —— 重放一笔已上链交易

通俗解释:交易失败了却不知道为什么?用它把交易原样重放一遍,看清每一步内部调用。

参数类型说明
txHashDATA, 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 —— 追踪一笔还没广播的交易

参数类型说明
rawTxDATA已签名交易的 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 —— 重放单笔交易

参数类型说明
txHashDATA, 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_getHeaderByNumberblockNumber:QUANTITY
erigon_getHeaderByHashblockHash: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 时间戳(秒)
fullTxBoolean是否返回完整交易
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 已实现,但很多服务商暂时还不支持。

参数类型说明
signedTxDATA已签名交易
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_signaddress、data65 字节签名对消息签名,签名对象是 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)

为什么实际开发中几乎不用它们?

  1. 公共 RPC 服务商不会替你保管私钥,调用会返回“账户不存在”之类的错误;
  2. Geth v1.14 起移除了 personal 命名空间和 --unlock 参数,v1.17.8 里只有 --dev 开发模式(自动创建并解锁一个账户),或者通过 --signer 接入外部签名器 Clef 时才能使用;
  3. 把私钥放在一台对外提供 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_getFilterChangeseth_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.validationBoolean是否像真实交易一样校验 nonce、余额、费用
opts.traceTransfersBoolean是否把 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_contentFromaddress只返回某个地址的交易
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 怎么填:

场景tovaluedata
ETH 转账收款人地址转账金额空
代币转账代币合约地址0transfer(收款人, 数量) 的 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 小结:一张图串起常见开发场景

常见开发场景与 JSON-RPC 方法总览


第四部分: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 / nodeJSON-RPC 服务端与客户端,连接外部世界的大门4.6
邮政与交通网p2p找到其他节点、建立加密通信4.7
物流调度中心eth(handler / fetcher / downloader / catalyst)、core/txpool交易池、交易广播、同步、配合共识层出块4.8
档案检索台eth/filters、core/filtermaps快速查日志、支持订阅4.9
计算工厂core/vmEVM:执行合约字节码、按 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
ethclientJSON-RPC 客户端(含 gethclient)第三部分全部示例
ethdb数据库抽象:pebble / leveldb / memorydb4.5
event进程内事件订阅(Feed)订阅推送的底层
graphqlGraphQL 查询接口—
internal/ethapieth_ 命名空间大部分方法的服务端实现第二部分 2.3、4.6
miner组装区块(合并后不再挖矿)4.8
node节点容器:组装服务、启动 HTTP / WS / IPC / authrpc、打开数据库4.6
p2p点对点网络:节点发现、RLPx、拨号4.7
params链配置、硬分叉时间、gas 常量—
rlp序列化4.3
rpcJSON-RPC 框架4.6
signer独立签名器 Clef 的实现—
trie / triedbMerkle 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、codeHashtypes.StateAccount;eth_getProof 返回的四个字段
T_n / T_p / T_g / T_t / T_v / T_d交易的 nonce、gasPrice、gasLimit、to、value、datatypes.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、logsBloomheader.Root、header.TxHash、header.ReceiptHash、header.Bloom
R_u / R_l / R_b收据的累计 gasUsed、日志、bloomreceipt.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 670x80+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
整数 0800 的“去前导零大端表示”是空串
整数 150f单字节且 < 128,原样
整数 102482 04 000x0400 两个字节
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))
	}
}

encBuffer 示意:str 中只放数据,列表头单独记录位置,最后再拼接

  1. 开始写列表时,list() 只记下“这个列表从 str 的第几个字节开始”;
  2. 写完所有元素后,listEnd() 算出内容长度,进而知道这个列表头需要占几个字节,累加到 lhsize;
  3. 最后 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)与状态存储:带指纹的档案柜

它解决什么问题

以太坊要保存海量的“键 → 值”:地址 → 账户、存储槽 → 值、交易序号 → 交易、交易序号 → 收据。它要求:

  1. 查得快:按 key 快速找到 value;
  2. 能防篡改:整个集合有一个“总指纹”(根哈希)写进区块头,任何一个值被改,指纹都会变;
  3. 能证明:不用给出全部数据,也能证明“某个 key 的值就是这个”;
  4. 能留历史:改一个账户时不复制整棵树,最近区块的状态还能查。

Merkle Patricia Trie(MPT)把三种数据结构组合起来,同时满足这四点。

三块积木

积木 1:Trie(前缀树)——按字母一路往下找

普通 Trie:tea、ten、to、in、inn、int 共享前缀

in、inn、int 共用前缀 in 这条路。查找步数只和 key 的长度有关,和数据总量无关。缺点是:没有公共前缀的长 key 会拉出一长串“单线”节点。

积木 2:Patricia(压缩前缀树)——把单线路径压成一个节点

从 Trie 到 Patricia:一长串单线节点被压缩

“没有分岔的路就合并成一段”,大大减少了节点数量。

积木 3:Merkle 树——每个节点都带“封条指纹”

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 最好的一张图:

以太坊 MPT:4 个账户如何组织成扩展节点、分支节点和叶子节点

图里 4 个账户的 key(a711355、a77d337、a7f9365、a77d397)都以 a7 开头,于是:

  1. 根是一个扩展节点,存共享前缀 a7;
  2. 下面是分支节点,按下一个 nibble 分成 1、7、f 三路;
  3. 1 和 f 两路下面各只剩一个账户,直接用叶子节点收尾;
  4. 7 这一路的两个 key 还共享 d3,于是再接一个扩展节点 + 分支节点;
  5. 根节点的 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)是一个递归函数,按当前节点的类型分情况处理:

Trie 插入流程

删除是插入的逆过程,关键在于保持树的“最简形态”:子节点删除后如果剩下的还是 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 的分层结构:

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):
    1. 期望哈希 = 区块头里的根哈希;
    2. 按期望哈希取出证明里的节点并解码;
    3. 按 key 往下走一步,拿到下一个节点的哈希作为新的期望哈希;
    4. 重复,直到走到 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 与 rawdb 的分层结构

接口(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/pebbleCockroachDB 团队开发的 LSM 键值库,纯 Go 实现
LevelDBethdb/leveldb老节点沿用,--db.engine=leveldb 可指定
内存库ethdb/memorydbmap[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 结构存什么
hh + 区块号(8字节大端) + 区块哈希区块头
h … nh + 区块号 + n这个高度的规范链区块哈希
HH + 区块哈希区块哈希 → 区块号
bb + 区块号 + 区块哈希区块体(交易列表等)
rr + 区块号 + 区块哈希收据
ll + 交易哈希交易 → 所在区块(eth_getTransactionByHash 靠它)
cc + 代码哈希合约字节码
A / OA + 路径 / O + 账户哈希 + 路径pathdb 的账户树、存储树节点
fm-fm-r、fm-b、fm-p ……日志索引 filtermaps(4.9)

区块号用 8 字节大端编码,是为了让 key 的字典序正好等于区块号的数字顺序,按区块号范围遍历时就是一段连续的数据。

把第三部分串起来:eth_getTransactionByHash 先用 l + 交易哈希 查出它在哪个区块,再用 b + 区块号 + 区块哈希 取出区块体,从中拿到这笔交易。

freezer:冷数据档案库

已经“不可能再被重组”的老数据,会被搬进只追加的 freezer(ancient store):

freezer存放的表
chainheaders、hashes、bodies、receipts、bals(区块访问列表)
statestate 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),后厨有新菜就主动送过去。

包结构

rpc 包结构

传输方式和业务逻辑完全解耦:同一个 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):

  1. 方法必须导出(大写开头);
  2. 第一个参数可以是 context.Context(不算入 JSON 参数);
  3. 返回值最多 2 个:没有返回值、只返回结果、只返回 error、或者 (结果, error),error 必须在最后;
  4. 可选参数用指针表示,放在最后:客户端没传,就补一个 nil(只有指针类型的尾部参数才允许省略);
  5. 订阅方法:第一个参数必须是 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
}

rpc 方法注册流程

命名规则只靠两个小函数:

// 注册时:方法名首字母变小写  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 结构体注册位置举例
ethEthereumAPIinternal/ethapi/backend.go 的 GetAPIseth_gasPrice、eth_feeHistory、eth_syncing
ethBlockChainAPI同上eth_chainId、eth_getBalance、eth_call、eth_estimateGas、eth_getProof
ethTransactionAPI同上eth_getTransactionByHash、eth_sendRawTransaction
ethEthereumAccountAPI同上eth_accounts
ethFilterAPIeth/filters,启动时由 cmd/utils 注册eth_getLogs、eth_newFilter、eth_subscribe("logs")
ethDownloaderAPIeth/backend.go 的 APIs()eth_subscribe("syncing")
txpoolTxPoolAPIGetAPIstxpool_status、txpool_content
debugDebugAPI + tracers 的 APIGetAPIs + eth/tracersdebug_traceTransaction、debug_traceCall
netNetAPIeth/backend.gonet_version、net_peerCount
web3 / adminweb3API / adminAPInode/api.goweb3_clientVersion、admin_peers
engineConsensusAPIeth/catalyst,需要 JWT 认证engine_newPayloadV4、engine_forkchoiceUpdatedV3

在 v1.17.8 中没有 personal 命名空间,trace_*、erigon_* 也不是 Geth 实现的。这些调用都会落进上面“查不到 callback”的分支,返回 -32601。

一次请求的完整旅程(以 HTTP 为例)

一次 HTTP 请求在 rpc 包中的处理时序

关键步骤:

  1. 入口与中间件(node/rpcstack.go):HTTP 请求先经过 gzip、Host 白名单(--http.vhosts)、CORS(--http.corsdomain)几层中间件;如果是 authrpc(8551 端口),还多一层 JWT 校验。
  2. 请求校验(rpc/http.go):拒绝 PUT/DELETE,body 超过 5 MiB 返回 413,Content-Type 不对返回 415;一个不带 body 的 GET 直接返回 200,正好用来做健康检查。
  3. 解析参数:找不到方法返回 -32601;参数个数或类型不对返回 -32602;方法内部 panic 会被捕获,返回 -32603。
  4. 反射调用:callback.call 拼出 [接收者, ctx, 参数...],执行 fn.Call(...);返回的 error 不为空就转成错误响应,否则把结果 JSON 编码进 result。
  5. 超时:如果方法执行超过了 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断开

订阅的精妙之处:“先给号码,再开始叫号”

订阅的“先返回 ID 再激活”时序

// 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):

  1. newMessage:用自增计数器生成 id,把参数 json.Marshal 成 params;
  2. HTTP:sendHTTP 直接发一个 POST,同步读回响应。没有后台循环,所以 HTTP 客户端不支持订阅,调用 Subscribe 直接返回 ErrNotificationsUnsupported;
  3. WebSocket / IPC:交给后台的 dispatch 协程;
  4. 等待响应:有 error 字段就返回错误,result 为空返回 ErrNoResult,否则 json.Unmarshal 到你传入的结果指针里。

rpc.Client 的 dispatch 并发模型(图为早期版本,channel 名字已有变化,思路相同)

长连接时,很多 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 {

注意三点:

  1. 白名单按命名空间整体过滤,不能只开放某几个方法;
  2. 没写 --http.api 时,Geth 默认只开放 net 和 web3,所以自己搭节点一定要显式写上 eth;
  3. 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 30303discv4 + discv5(默认都开启,共用一个 socket)找节点:问路、打招呼发短信问路
TCP 30303RLPx传数据:在加密通道上跑 eth / snap 协议打加密电话

p2p Server 总体结构

默认参数(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
}
参数值含义
bucketSize16每个桶最多 16 个节点
maxReplacements10每个桶另有 10 个“候补”
nBuckets256/15 = 17只为最近的 17 个距离段建桶,更远的都塞进第 0 个桶
alpha3查找时同时问 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 密钥交换:

Diffie-Hellman 密钥交换示意

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

RLPx 加密握手时序

// 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 秒内不再打扰。

  1. 计算空闲名额:(最大出站数 − 已连出数) × 2,再减去正在拨号的数量。乘 2 是“超额拨号”,因为不少拨号会失败;
  2. 静态节点(--staticnodes 或配置里写死的节点)优先占用名额;
  3. 还有名额,才从节点发现的候选流里取动态节点;
  4. 每个候选都要检查:不是自己、没在拨号、没连上、不在网段黑名单、不在 35 秒冷却期内;
  5. 通过后记入冷却表,起一个 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 syncsnap 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 个块广播一次自己的数据范围,方便对方知道该向谁要历史数据。

主要消息码:

消息码消息作用
0x00Status握手
0x02Transactions直接推送完整交易
0x03 / 0x04GetBlockHeaders / BlockHeaders要区块头 / 回区块头
0x05 / 0x06GetBlockBodies / BlockBodies要区块体
0x08NewPooledTransactionHashes“我有这些新交易”(只发哈希)
0x09 / 0x0aGetPooledTransactions / PooledTransactions按哈希拉取交易
0x0f / 0x10GetReceipts / Receipts要收据
0x11BlockRangeUpdate声明自己的区块范围(eth/69)
0x12 / 0x13GetBlockAccessLists / BlockAccessLists区块访问列表(eth/71)
0x14 / 0x15GetCells / Cellsblob 数据分片(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)决定什么时候、向谁拉取完整交易:

交易 fetcher 状态流转

常量值含义
txArriveTimeout500ms公告后先等这么久,看对方会不会直接推送
txFetchTimeout5s拉取超时
maxTxAnnounces4096每个邻居最多挂这么多未处理的公告(防 DoS)
maxTxRetrievals256一次最多拉 256 笔
maxTxRetrievalSize128KB一次最多拉 128KB

被交易池以“价格太低”拒绝过的交易会记进一个 LRU 名单,5 分钟内不再重复拉取。

交易池:叫号大厅

core/txpool 是一个总池,下面挂两个子池:

子池接收的交易特点
legacypoolLegacy、AccessList(EIP-2930)、DynamicFee(EIP-1559)、SetCode(EIP-7702)常规交易,存内存
blobpoolBlob 交易(EIP-4844,type 3)体积大,存磁盘;每账户最多 16 笔;替换要求加价 100%

一笔交易进池前要过两道检查:

  1. 无状态检查(ValidateTransaction):交易类型是否支持、大小、对应硬分叉是否已激活、gas 不超过区块上限、maxFeePerGas ≥ maxPriorityFeePerGas、签名能恢复出发送者、intrinsic gas 够不够、小费不低于节点下限……
  2. 有状态检查(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%。

同步:先铺骨架,再挂车厢

Engine API 驱动的同步时序

谁来驱动? 不再是“找总难度最高的邻居”,而是 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 的步骤:

  1. 把账户哈希空间切成 16 段并发下载,大合约的存储也切成 16 段;
  2. 每批数据都附带默克尔证明,收到后用 pivot 的 stateRoot 校验;
  3. 下载期间链头一直在前进,各段数据可能来自不同的状态版本,于是最后进入 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 目录已经删除,数据库里只保留了清理旧数据的代码。

第一层:区块头里的布隆过滤器(仍在计算)

日志、收据与 bloom 的关系(黄皮书示意)

  • 一条日志 = (合约地址,若干 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_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++
}

EVM 解释器主循环

注意两点:

  1. 先计费、后执行:钱不够的指令根本不会执行;
  2. 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(调用合约),对应第三部分的“调用合约写方法”:

  1. 调用深度超过 1024 → ErrDepth;余额不足 → 报错;
  2. 打快照 StateDB.Snapshot();
  3. 目标是预编译合约(如 ecrecover、sha256)→ 直接运行 Go 实现的预编译代码;
  4. 目标账户不存在、又不是预编译、并且没有转账 → 什么都不做,直接返回(EIP-158);
  5. 转账;加载目标地址的代码,evm.Run 执行;
  6. 出错就 RevertToSnapshot,只回滚这一层的修改。

Create(部署合约),对应第三部分的“部署合约”:

  1. 深度、余额、nonce 预检查;发送者 nonce + 1;
  2. 计算新地址:CREATE 用 CreateAddress(部署者, nonce),CREATE2 用 CreateAddress2(部署者, salt, keccak256(initcode))。所以部署前就能算出合约地址;
  3. 地址冲突检查 → 打快照 → 创建账户(新合约的 nonce 为 1)→ 转账;
  4. 执行初始化代码(构造函数),它的返回值才是合约的运行时代码;
  5. 检查运行时代码:不能以 0xEF 开头(EIP-3541)、不能超过 24576 字节;按 代码长度 × 200 gas 扣“存代码费”,然后写入;
  6. 任何一步出错就回滚到快照。

这解释了 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 转账,走完它的一生:

一笔交易在 Geth 里的完整旅程

  1. 你在本地签名,通过 eth_sendRawTransaction 把RLP 编码的交易交给节点;
  2. rpc.Server 切出 eth + sendRawTransaction,反射调用 TransactionAPI.SendRawTransaction(4.6);
  3. 交易解码、验签,通过交易池的两道检查后进入 pending,返回交易哈希(4.8);
  4. eth 协议把交易发给 √N 个邻居,其余邻居只收到哈希,再由它们的 TxFetcher 来拉取(4.7、4.8);
  5. 轮到某个验证者出块时,它的共识层客户端通过 Engine API 让 Geth 组装区块:EVM 逐笔执行交易,修改 StateDB,产生收据和日志(4.10);
  6. 区块经共识层广播;每个节点的 Geth 收到 engine_newPayload 后重新执行、验证,算出相同的 stateRoot,把改动写入 pathdb 和数据库(4.3、4.4、4.5);
  7. engine_forkchoiceUpdated 把新区块设为链头,事件系统通知所有订阅者,日志进入索引(4.9);
  8. 你调用 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 lowgasLimit 连交易的基础门票都不够4.10
missing trie node节点不再保存该区块的历史状态,需要归档节点4.4
notifications not supported用 HTTP 连接调用了订阅方法1.2、4.6
ethereum.NotFound(not found)交易还没被打包,或者查询的对象不存在3.5

附录 B:延伸阅读