以太坊 PoS 共识机制与 Engine API 源码阅读

1. 理论基础

1.1 以太坊共识机制的历史演进

1. 历史背景:工作量证明 (Proof-of-Work) 时代

在2015年主网上线至2022年9月15日‘The Merge’完成前,以太坊使用 工作量证明(Proof-of-Work, PoW) 作为其共识机制,与比特币在机制设计上相似,但具体实现(如Ethash算法)不同。这意味着网络的安全性依赖于‘矿工’——他们通过执行工作量证明算法(Ethash),在满足难度目标的前提下,计算出符合要求的区块哈希值,从而竞争获得出块权及区块奖励。

然而,随着以太坊生态的发展,PoW 机制暴露了几个核心问题:

  • 巨大的能源消耗:PoW 挖矿需要消耗海量的电力。据剑桥大学比特币电力消耗指数(CBECI)及以太坊基金会估算,在PoW时期,以太坊全网年耗电量峰值约为112.15 TWh/年(2021年数据),与荷兰等中等国家相当。
  • 硬件的中心化趋势:尽管以太坊的Ethash算法设计初衷是抗ASIC,但在2018年后,专用的ASIC矿机仍被开发并逐步主导市场。这导致算力最终向少数专业的矿场和大型矿池集中,削弱了网络的去中心化程度。
  • 扩展性瓶颈:PoW 机制下,为保障网络安全性与去中心化,区块生成时间被设定为约12-14秒,且区块Gas Limit限制了单区块交易容量,导致网络理论峰值TPS约为15-30,难以支撑大规模商业应用。

2. The Merge:向权益证明 (Proof-of-Stake) 的转变

为了解决上述问题,以太坊社区经过多年研发,2022年9月15日,以太坊通过‘The Merge’(合并)升级,将执行层(原PoW主网)与共识层(原信标链)合并,正式从工作量证明(PoW)过渡到权益证明(Proof-of-Stake, PoS)。

PoS 的核心安全模型,是基于‘可量化的经济惩罚’。这标志着以太坊安全哲学的根本转变:从依赖于物理世界的能源消耗和硬件投入(PoW 的‘物理成本’),转向了依赖于协议内部的经济抵押和可编程罚没(PoS 的‘经济成本’)。这种转变使得安全性不再与外部能源市场挂钩,而是内化为协议自身的经济博弈。

这次转变的主要目标和优势包括:

  • 可持续性:根据以太坊基金会官方数据,转向 PoS 后,以太坊网络能耗下降超过 99.9%,从约 112 TWh/年降至约 0.01 TWh/年,降幅达三个数量级。
  • 改变去中心化的维度:PoS 消除了对专用硬件的依赖,理论上降低了参与门槛。然而,成为独立验证者需质押 32 ETH,导致大量用户通过质押池(如Lido、Rocket Pool)或中心化交易所参与,引发对资本集中化和第三方信任依赖的新担忧。截至2024年,前三大质押服务商控制超过50%的质押ETH,构成潜在中心化风险。
  • 为未来扩展铺路:PoS 为以太坊未来的可扩展性路线图(如Danksharding)提供了基础架构支持。在PoW下,分片难以安全实现验证者随机分配;而PoS通过验证者委员会机制,使分片链的安全性和数据可用性成为可能。

1.2 权益证明下的核心角色:验证者(Validators)

在以太坊转向 PoS 之后,“矿工”的角色被“验证者”所取代。验证者是 PoS 机制下网络安全和共识的直接参与者和维护者。

1.2.1 谁是验证者?

验证者(Validator)是权益证明(PoS)机制下,通过向以太坊存款合约质押至少 32 ETH,注册并激活后获得参与区块提议与共识投票权限的网络节点运营者。其行为受协议经济激励与惩罚机制约束,是网络安全与最终性(Finality)的核心保障者。

  • PoW 的安全性建立在‘计算成本’之上——攻击者需控制超50%算力并承担高昂硬件与电力成本。
  • PoS 的安全性建立在‘经济成本’之上——攻击者需控制超33%质押ETH,并承担被罚没(Slashing)的巨额经济损失。

质押的 32 ETH 不仅仅是经济抵押品(Collateral) ,更是验证者自愿进入并遵守协议规则的 “游戏押金” 。它确保了验证者在网络中有“切肤之痛”(Skin in the game),其个人经济利益与网络的整体健康被牢牢绑定。任何偏离规则的行为都将直接导致这笔押金的损失。若验证者违反协议规则(如双重签名、环绕投票),将触发 罚没(Slashing) 机制,导致部分或全部质押金被销毁。此外,长期离线或未能及时提交证明,将遭受怠工惩罚(Inactivity Leak),导致余额缓慢减少。

1.2.2 如何成为一名验证者?

要成为以太坊网络的完整验证者(Full Validator),需满足以下协议层强制要求:

  1. 质押 32 ETH: 这是最核心的经济门槛。需向以太坊信标链的官方存款合约(0x0000…deposit)存入恰好 32 ETH。该操作不可逆,且资金在验证者完全退出并完成退出队列等待期(约 27 小时至数日)前无法提取。 自上海升级(2023年4月)后,已激活验证者可发起部分或全部提款。

  2. 运行硬件和软件

    • 硬件:需运行满足最低规格的硬件,并确保99.9%以上在线率。离线将导致奖励损失,长期离线触发怠工惩罚。。
    • 软件:必须同时运行一个执行客户端(Execution Client)(如 Geth、Nethermind、Besu)与一个共识客户端(Consensus Client)(如 Prysm、Lighthouse、Teku、Nimbus),二者通过Engine API通信。推荐使用异构客户端组合以降低网络同质化风险。
  3. 生成密钥并激活

    • 在存款前,需使用标准工具(如 eth2-val-tools 或 staking-deposit-cli)生成:
      • 验证者签名密钥(BLS 密钥对):这是“热钱包”密钥,必须保持在线以执行日常职责,因此风险较高。
      • 取款凭证(Withdrawal Credentials):这是“冷钱包”凭证,决定了质押本金和奖励的最终归属,一旦设定(特别是为 0x01 地址类型),即使签名密钥被盗,攻击者也无法转移资金 。这是一种关键的风险隔离设计。
    • 完成存款后,验证者身份不会立即激活,需要进入一个激活队列(Activation Queue) 排队等待,以确保网络验证者的数量平稳增长。

1.2.3 验证者的核心职责

以太坊 PoS 将时间划分为时隙(Slot,12秒)纪元(Epoch,32个时隙,约6.4分钟)。时隙(Slot)为最小时间单位,每个时隙预期产生一个区块。纪元(Epoch)用于重组验证者委员会、计算奖励/惩罚、处理激活/退出。

在每个时隙中,验证者都有可能被分配到以下两种职责之一:

  1. 区块提议 (Block Proposing): 在每个时隙,信标链通过RANDAO 机制,伪随机地从活跃验证者集中选出一名区块提议者(Proposer)。该验证者负责从内存池收集交易、构建执行层Payload、打包为信标区块并广播。

  2. 区块证明 (Attesting): 其余活跃验证者被分配至一个委员会(Committee)(每委员会约128–2048人),负责在指定时隙内对区块进行证明(Attestation),包括:1.对区块头有效性投票(LMD GHOST);2.对检查点(Checkpoint)投票以实现最终性(Casper FFG)。

简单来说,每个时隙都是“一个提议,众人证明”的过程。

1.3 共识机制详解:从时隙证明到经济最终性

1.3.1 一个时隙(Slot)内的微观时序

每个时隙(12秒)内,协议通过精确时间窗口协调区块传播与证明提交,确保高效达成局部共识:

  • t = 0s:时隙开始。为确保证明者有足够时间投票,提议者应在4秒内广播区块。延迟广播的区块将无法获得足够投票权重,不被主链采纳,导致提议者损失全部出块奖励。
  • t = 4s证明提交截止。所有验证者必须广播其对当前时隙区块(Head)和目标检查点(Target)的证明。
  • t = 8s聚合阶段。每个委员会中1–16名聚合者(伪随机选出)收集相同投票内容的证明,利用BLS签名聚合为单一签名+位图,并广播。
  • t = 12s:时隙结束。节点基于已接收的证明更新本地链视图。未被及时打包的证明仍可在后续32个时隙内被包含并获得衰减奖励,但其对当前时隙共识(链选择与最终性)的贡献已失效。

此时序设计确保网络在高延迟环境下仍能收敛,同时激励验证者及时响应。

1.3.2 证明的聚合(Attestation Aggregation)

为应对数万验证者带来的通信压力,以太坊采用BLS签名聚合机制

  • 聚合者(Aggregator):每委员会每时隙伪随机选出1–16名,负责聚合证明。
  • 聚合前提:仅可聚合投票内容完全相同(Head + Target)的证明。
  • 技术实现:利用BLS签名线性特性,将N个签名聚合成一个96字节聚合签名 + 验证者索引位图(Bitfield)。
  • 核心优势:单委员会(128–2048人)证明压缩率>99%,通信与存储开销大幅降低,是PoS可扩展性的基石。

1.3.3 投票驱动的链状态更新

节点通过解析聚合证明中的投票,驱动两个核心协议:

1. LMD GHOST(分叉选择规则)

  • 全称:Latest Message-Driven Greediest Heaviest Observed SubTree。
  • 机制:仅统计每个验证者最新提交的证明,按其质押余额加权,计算各区块子树的累积权重。在分叉点选择权重最大路径。
    • “贪心” (Greediest) 体现:节点在评估分叉时,仅沿区块树向下选择“当前可见权重最大”的子树,不做全局最优搜索或回溯,从而实现低延迟的实时链选择。

  • 目标:在存在网络延迟或临时分叉时,仍能快速收敛到一致链头。

2. Casper FFG(最终性小工具)

  • 作用对象:纪元边界区块(Checkpoint)。
  • 合理化(Justification):若检查点 C 获得 ≥ 2/3 当前活跃验证者总质押余额的Target投票,则被标记为“合理化”。
  • 敲定(Finalization):若存在连续两个合理化检查点 A → B(B为A的直接后代),则A被“敲定”。
  • 经济最终性:逆转敲定区块需攻击者控制并损失 ≥ 1/3 总质押ETH(理论最小罚没量),提供比PoW“概率最终性”更强的确定性保证

可以将 LMD GHOST 和 Casper FFG 理解为一对 “现在”与“过去”的守护者

  • LMD GHOST 负责当下:它是一个高效的、实时运行的分叉选择规则,用于快速决定哪个区块是当前的链头(liveness),保证链能持续向前推进。它的决策是可逆的
  • Casper FFG 负责历史:它是一个周期性运行的最终性工具,以约6.4分钟(一个 Epoch)的粒度,将最近的区块历史“盖棺定论”,使其不可逆转(safety)。

因此,节点总是跟随 GHOST 协议选择的“最新”链头,而 FFG 协议则在身后不断地将这条链的历史固化下来。二者协同,既保证了网络的活性,又提供了强大的经济最终性。

1.4 激励与惩罚机制:驱动验证者行为的经济引擎

以太坊 PoS 的安全性和稳定性,并非仅靠技术协议保障,其背后是一套精密设计的经济激励与惩罚机制。该机制通过奖励诚实行为、惩罚无意失职、严惩恶意攻击,确保验证者的个人利益与整个网络的健康发展高度一致。

1.4.1 验证者的奖励(Rewards)

验证者收入来源于三部分:

  1. 协议基础奖励(由新发行ETH支付)
  2. 执行层交易优先费(Priority Fees)(由用户支付,归属区块提议者)
  3. MEV 收益(非协议内,由市场行为产生)

奖励按职责发放:

  • 证明奖励:为获得全额奖励,证明需正确投票以下三项,每项独立计算奖励:
    • Source:对上一合理化检查点(Justified Checkpoint)的投票
    • Target:对当前纪元检查点(Target Checkpoint)的投票
    • Head:对最新区块头(Block Head)的投票
  • 区块提议奖励:除协议规定的基础奖励外,提议者获得该区块内所有交易的优先费(Priority Fee / Tip)。基础交易费(Base Fee)被协议销毁。
  • 同步委员会奖励:每 256 个 Epoch(≈27.3小时)轮换512名验证者,为轻客户端提供支持,奖励显著高于普通证明。
  • 举报奖励:首个打包有效罚没证据的提议者,可获得被罚没余额的 1/512(最小 0.0625 ETH)作为奖励。

1.4.2 验证者的惩罚(Penalties)

  • 惩罚:主要针对无意的失职行为,如节点掉线、网络延迟等。其后果通常是错失应得的奖励,本质上是“没赚到钱”,一般不扣减本金(怠工惩罚是例外)。
  • 罚没:只针对明确的、恶意的、破坏共识的行为。其后果是扣罚部分或全部质押本金,是“本金亏损”,并被强制踢出验证者网络。

一个是“对懒惰的惩罚”,一个是“对作恶的制裁”。

1.4.3 罚没(Slashing)

对恶意行为的终极惩罚,旨在威慑对网络安全构成严重威胁的行为。

  • 可罚没行为 (Slashing Offenses)

    1. 双重区块提议 (Double Block Proposal):在同一时隙签署并广播两个不同区块头。
    2. 环绕投票 (Surround Vote):提交一个投票 (S1→T1),其时间范围完全包含之前投票 (S0→T0)(即 S0 < S1 < T1 < T0)。通俗地说,这相当于一个验证者先投票承认了“从 Epoch 10 到 Epoch 20 的历史”,随后又投了另一票,试图将“从 Epoch 12 到 Epoch 18 的历史”重写为另一段完全不同的内容。这种行为创造了无法调和的历史矛盾,是对最终性的直接攻击。
  • 罚没的流程与后果

    1. 最小罚没 (MIN_SLASHING_PENALTY):一经确认,立即扣除 1 ETH(协议常量)。
    2. 强制退出:验证者被标记为 SLASHED,并进入 8192 Epoch 的退出期(≈36.4天)。在此期间,其余额会因怠工惩罚而持续衰减。
    3. 协同惩罚 (Proportional Slashing):在退出期中点(Epoch 4096),施加额外惩罚:额外罚没比例 = min(0.5, 3 × (同期被罚没数 / 总活跃数))
      • 若10%验证者协同作恶 → 损失约30%余额
      • 若≥16.7%验证者协同作恶 → 损失50%余额(上限) 此机制确保大规模攻击的经济成本呈指数级上升。 好的,明白了。是我理解错了,你是希望我将第二章的全部内容,按照你第一章的风格和结构,完整地重新组织和撰写一遍。

2. 源码阅读

2.1 历史演进的源码体现

以太坊的代码库如同一部活的历史书,记录了从 PoW 到 PoS 的每一次重大变迁。通过阅读源码,可以清晰地看到历史留下的印记,以及“合并”(The Merge)这一里程碑事件是如何通过精巧的机制被精确触发的。

2.1.1 PoW 时代的“代码化石”

尽管以太坊的共识机制已全面转向 PoS,但在当前的 Geth 代码库中,PoW 时代的共识逻辑作为历史代码被完整保留。它们就像“代码化石”,见证了以太坊的过去。这些代码主要分布在以下两个目录:

  • consensus/ethash/: 包含了完整的 Ethash 工作量证明算法的实现。从随机数数据集(DAG)的生成到区块哈希的计算,所有 PoW 挖矿的核心算法都封装于此。
  • miner/: 包含了传统 PoW 挖矿的工作流,例如交易打包、区块密封(Sealing)、挖矿线程管理等逻辑。其中部分功能(如交易打包逻辑)在 PoS 时代被执行层复用,但核心的“挖矿”部分已不再被主网调用。

2.1.2 “The Merge”

“The Merge”的触发并非基于特定的区块高度或时间戳,而是通过一个精确的全局状态——终端总难度 (Terminal Total Difficulty, TTD) 来实现的。这个设计确保了无论全网算力如何波动,切换都能在一个可预测的“总工作量”完成点上发生,避免了分叉风险。

TTD 是一个由以太坊核心开发者预先设定的、巨大的总难度目标值。它作为主网配置的一部分,被硬编码在 Geth 客户端中。

// params/config.go
MainnetTerminalTotalDifficulty, _ = new(big.Int).SetString("58_750_000_000_000_000_000_000", 0)
// MainnetChainConfig 是在主网上运行节点的链参数。
MainnetChainConfig = &ChainConfig{
	ChainID:                 big.NewInt(1),
	HomesteadBlock:          big.NewInt(1_150_000),
	DAOForkBlock:            big.NewInt(1_920_000),
	DAOForkSupport:          true,
	EIP150Block:             big.NewInt(2_463_000),
	EIP155Block:             big.NewInt(2_675_000),
	EIP158Block:             big.NewInt(2_675_000),
	ByzantiumBlock:          big.NewInt(4_370_000),
	ConstantinopleBlock:     big.NewInt(7_280_000),
	PetersburgBlock:         big.NewInt(7_280_000),
	IstanbulBlock:           big.NewInt(9_069_000),
	MuirGlacierBlock:        big.NewInt(9_200_000),
	BerlinBlock:             big.NewInt(12_244_000),
	LondonBlock:             big.NewInt(12_965_000),
	ArrowGlacierBlock:       big.NewInt(13_773_000),
	GrayGlacierBlock:        big.NewInt(15_050_000),
	TerminalTotalDifficulty: MainnetTerminalTotalDifficulty, // 关键的TTD字段 58_750_000_000_000_000_000_000
	ShanghaiTime:            newUint64(1681338455),
	CancunTime:              newUint64(1710338135),
	PragueTime:              newUint64(1746612311),
	DepositContractAddress:  common.HexToAddress("0x00000000219ab540356cbb839cbe05303d7705fa"),
	Ethash:                  new(EthashConfig),
	BlobScheduleConfig: &BlobScheduleConfig{
		Cancun: DefaultCancunBlobConfig,
		Prague: DefaultPragueBlobConfig,
	},
}

2.2 链配置(ChainConfig)的加载与应用

params.MainnetChainConfig 是一个全局的结构体变量,它定义了以太坊主网所有的规则,包括各次硬分叉的激活点、ChainID、TTD 等关键参数。它是 Geth 节点“认识”并正确同步主网的身份凭证。

在代码库中,这个配置对象在两个主要的场景下被加载和使用:一是 Geth 节点作为在线服务启动时;二是通过命令行工具执行离线数据操作时。

2.2.1 启动时加载:LoadChainConfig 的调用路径

这条路径是 Geth 作为一个全节点启动并同步主网时最常规的流程。目的是根据用户指定的网络 ID(或默认的主网 ID),加载对应的链配置。

调用流程如下:

  1. cmd/geth/main.go -> geth():Geth 程序的命令行入口,负责解析参数并启动节点。

  2. geth() -> makeFullNode() :创建以太坊“全节点”实例的核心函数,负责组装所有服务模块。

  3. makeFullNode() -> eth.New():在创建和注册核心的 eth 服务时,会调用 eth.New 来构建以太坊协议栈的实例。

  4. eth.New() -> core.LoadChainConfig():在 eth.New 函数内部,Geth 需要确定将要运行在哪条链上。它会调用 core.LoadChainConfig 来获取一个包含所有硬分叉信息的完整 ChainConfig 对象。

  5. core.LoadChainConfig():此函数是配置加载的核心,其逻辑遵循明确的优先级顺序:

    1. 优先从数据库加载:函数首先尝试从数据库中读取已存储的链配置。如果数据库非空且已存在一个规范链的创世区块,则直接返回与之对应的配置。这确保了节点重启时会沿用之前的链。
    2. 其次使用创世文件:如果数据库为空,但调用时传入了一个 genesis 对象,则使用该 genesis 对象内嵌的配置。
    3. 最后回退到主网默认值:如果数据库为空,且没有提供 genesis 对象,函数会回退到默认配置,即返回 params.MainnetChainConfig
// core/genesis.go
func LoadChainConfig(db ethdb.Database, genesis *Genesis) (cfg *params.ChainConfig, ghash common.Hash, err error) {
    // 从数据库加载已存储的链配置。
    stored := rawdb.ReadCanonicalHash(db, 0)
    if stored != (common.Hash{}) {
        storedcfg := rawdb.ReadChainConfig(db, stored)
        if storedcfg != nil {
            return storedcfg, stored, nil
        }
    }
    // 从提供的创世规范中加载配置。
    if genesis != nil {
        if genesis.Config == nil {
            return nil, common.Hash{}, errGenesisNoConfig
        }
        ghash := genesis.ToBlock().Hash()
        if stored != (common.Hash{}) && ghash != stored {
            return nil, ghash, &GenesisMismatchError{stored, ghash}
        }
        return genesis.Config, ghash, nil
    }
    // 如果没有存储的链配置,也没有提供新的配置,则使用默认的链配置(主网)。
    return params.MainnetChainConfig, params.MainnetGenesisHash, nil
}

因此,这条路径的本质是:Geth 启动 -> 创建节点 -> 初始化以太坊服务 -> 根据 ChainID=1 加载主网的预设配置

2.2.2 命令行工具调用:chainConfigOrDefault 的回退机制

许多 geth 的子命令,如 init, import 等,是在离线状态下操作本地区块数据的。这些工具同样需要准确的链配置,它们依赖 SetupGenesisBlockWithOverride 函数来初始化或验证创世状态。

SetupGenesisBlockWithOverride 函数的逻辑非常复杂,它负责处理数据库的从零初始化、配置覆盖和兼容性检查。在确定最终使用的链配置时,它会调用一个名为 chainConfigOrDefault 的内部方法。

// core/genesis.go

// SetupGenesisBlockWithOverride 负责写入或验证创世区块,并返回最终的链配置。
// 其内部在需要确定配置时会调用 chainConfigOrDefault。
func SetupGenesisBlockWithOverride(db ethdb.Database, triedb *triedb.Database, genesis *Genesis, overrides *ChainOverrides) (*params.ChainConfig, common.Hash, *params.ConfigCompatError, error) {
    // ... 函数体非常长,处理各种初始化和验证场景 ...

    // 在函数的后半部分,当需要对比新旧配置时,会进行如下调用:
    newCfg := genesis.chainConfigOrDefault(ghash, storedCfg)
    
    // ... 后续逻辑为检查配置兼容性并写入数据库 ...
    
    return newCfg, ghash, nil, nil
}

// chainConfigOrDefault 根据上下文确定最终的链配置。
func (g *Genesis) chainConfigOrDefault(ghash common.Hash, stored *params.ChainConfig) *params.ChainConfig {
    switch {
    case g != nil:
        return g.Config
    case ghash == params.MainnetGenesisHash:
        return params.MainnetChainConfig
    case ghash == params.HoleskyGenesisHash:
        return params.HoleskyChainConfig
    case ghash == params.SepoliaGenesisHash:
        return params.SepoliaChainConfig
    case ghash == params.HoodiGenesisHash:
        return params.HoodiChainConfig
    default:
        return stored
    }
}

chainConfigOrDefault 的决策逻辑比 LoadChainConfig 更为精细,专为 init 等场景设计:

  1. 优先使用传入的 Genesis 对象:如果 init 命令传入了一个 genesis 对象(即用户提供了 genesis.json),则无条件使用该文件内指定的 Config。这是最高优先级,保证了自定义网络可以被正确初始化。
  2. 其次根据创世哈希推断:如果没有传入 genesis 对象,函数会检查数据库中已存在的创世区块哈希(ghash)。如果这个哈希匹配任何一个已知的公共网络(主网、Holesky、Sepolia 等),则返回对应的硬编码配置。
  3. 最后使用已存储的配置:如果创世哈希不匹配任何已知网络,函数会返回从数据库中读出的 stored 配置。

这条路径的本质是:命令行工具 -> 需要初始化或验证创世状态 -> 优先采用用户指定的创世配置,若无则根据创世哈希匹配公共网络,最后才沿用数据库中已有的未知网络配置

2.3 Engine API: 执行层与共识层的握手

在权益证明(PoS)机制下,原有的 miner 模块被废弃,区块的“提议”和“证明”职责转移到了共识客户端(Consensus Client, CL)。然而,交易的打包、执行以及世界状态的管理,仍然由执行客户端(Execution Client, EL),即 Geth 负责。Engine API 是为了协调这两者而设计的标准接口,它定义了 CL 如何命令 EL 执行任务,以及 EL 如何将结果反馈给 CL。

本质上,Engine API 将 Geth 从一个独立的、负责挖矿的全节点,转变为一个由 CL 驱动的“状态机”和“交易执行引擎”。

2.3.1 PoS 时代的区块构建流程

一个区块的诞生过程,清晰地展示了 CL 和 EL 是如何通过 Engine API 协作的。当一个验证者被选为下一个时隙(Slot)的提议者时,流程如下:

  1. 准备阶段 (Preparation)

    • CL: 在确定了将要构建新区块的父区块(Head Block)后,向 EL 发出准备指令。
    • CL -> EL: 调用 engine_forkchoiceUpdated 方法。此调用有两个核心目的:
      • 更新分叉选择: 告知 EL 当前规范链的链头(headBlockHash)是哪个,确保 EL 与 CL 的链视图对齐。
      • 触发区块构建: 传入一个 payloadAttributes 参数,其中包含新区块的时间戳、prevRandao、提款(withdrawals)列表等构建区块所需的所有元数据。这相当于对 EL 发出指令:“请基于指定的链头,开始异步准备一个执行载荷(Execution Payload)”。
    • EL: 接收到指令后,首先进行版本校验,然后执行核心的分叉选择更新逻辑。这包括与 CL 对齐链视图(链头、安全及最终确定块)。在完成链状态更新后,如果请求中包含了 payloadAttributes,EL 才会调用内部的 miner 模块在后台异步准备一个执行载荷(Execution Payload)。
  2. 构建阶段 (Building)

    • CL: 等待片刻,给予 EL 足够的时间完成区块构建后,正式向 EL 请求执行载荷。
    • CL -> EL: 调用 engine_getPayload 方法,并传入一个在上一步 forkchoiceUpdated 调用中获得的唯一 payloadId
    • EL: 接收到请求后,从内部缓存中取出已经构建完成的 ExecutionPayload,并将其返回给 CL。
  3. 密封与广播 (Sealing & Broadcasting)

    • CL: 收到 ExecutionPayload 后,将其封装进一个信标区块(Beacon Block)的结构中,完成签名,然后将这个完整的、签过名的信标区块广播至网络。

2.3.2 新区块的验证流程

当节点从网络上接收到一个新的区块时,验证流程与构建流程相对应:

  1. CL: 从 P2P 网络接收到一个新的信标区块,并进行共识层面的基本验证(例如,签名是否有效)。
  2. CL -> EL: 从信标区块中提取出 ExecutionPayload,然后通过 engine_newPayload 方法将其发送给 EL 进行验证。
  3. EL: 接收到 ExecutionPayload 后,执行一系列严格的前置检查,这些检查与网络硬分叉版本紧密相关(例如,上海升级后 withdrawals 字段不可为空)。通过检查后,EL 会执行载荷中的所有交易,并校验执行后的状态根、收据根等是否与载荷头中声明的一致。执行完毕后,向 CL 返回一个明确的状态码。通常是 VALID(验证通过)、INVALID(验证失败),或在缺少父区块等前置条件时返回 ACCEPTED(已接收但延迟处理)
  4. CL: 根据 EL 返回的状态,结合自身的 LMD GHOST 和 Casper FFG 共识规则,最终决定是否接受这个区块并将其更新为新的链头。

2.3.3 核心逻辑: forkchoiceUpdated

所有版本检查的最终指向都是内部的 forkchoiceUpdated 函数。它负责同步 CL 的链视图,并在必要时触发新区块的构建。所有外部的 ForkchoiceUpdatedV* 方法最终都会调用这个内部函数。其执行流程大致如下:

  1. 检查本地是否存在链头区块: 函数首先使用 api.eth.BlockChain().GetBlockByHash 检查 CL 指定的 headBlockHash 是否已经存在于 EL 的数据库中。

  2. 处理未知区块 (block == nil):

    • 如果区块不存在,意味着 EL 的链视图落后于 CL。
    • EL 会首先检查这个未知的区块哈希是否曾被标记为无效链的一部分 (checkInvalidAncestor),以避免同步无效的分叉。
    • 然后,EL 会尝试从网络中获取该区块头 (api.eth.Downloader().GetHeader),并触发一个信标链同步过程 (api.eth.Downloader().BeaconSync) 来追赶 CL 的链头。在此期间,EL 向 CL 返回 STATUS_SYNCING
  3. 处理已知区块:

    • 如果区块存在于本地,函数会进行一系列检查和状态更新。
    • 设置规范链头: 这是一个关键步骤。EL 会检查 CL 提供的 headBlockHash 是否是其本地已知的规范链头。
      • 如果该区块不在当前规范链上(通过 rawdb.ReadCanonicalHash 检查发现不匹配),EL 会调用 api.eth.BlockChain().SetCanonical(block) 将主链切换到这个新的链头上,这可能会引发链重组(re-org)。
      • 如果该区块就是当前的规范链头,则跳过此步骤。
      • 还有一种特殊情况:如果该区块是一个旧的、已在规范链上的祖先区块,EL 会识别出这是一次过时的更新,不会调用 SetCanonical,而是直接忽略该请求。因此,并非所有“与当前规范哈希不一致”的情况都会触发链重组。
    • 更新最终状态: 根据 CL 提供的 FinalizedBlockHashSafeBlockHash,调用 SetFinalizedSetSafe 方法更新 EL 本地链的最终敲定和安全状态。
  4. 触发区块构建:

    • 如果 CL 的请求中包含了 payloadAttributes(即 CL 希望提议一个新区块),EL 会将这些属性(时间戳、手续费接收地址等)打包成一个 miner.BuildPayloadArgs 结构体。
    • 最后调用 api.eth.Miner().BuildPayload(args, ...) 来异步启动交易打包和状态执行。
    • 成功启动后,生成的载荷(payload)将被缓存在 api.localBlocks 中,并返回一个 payloadId 给 CL,以便后续通过 getPayload 获取。
// catalyst/api.go

func (api *ConsensusAPI) forkchoiceUpdated(...) (engine.ForkChoiceResponse, error) {
    // ... (锁定与基本检查)

    block := api.eth.BlockChain().GetBlockByHash(update.HeadBlockHash)
    if block == nil {
        // ... (触发同步)
        return engine.STATUS_SYNCING, nil
    }

    if rawdb.ReadCanonicalHash(api.eth.ChainDb(), block.NumberU64()) != update.HeadBlockHash {
        if _, err := api.eth.BlockChain().SetCanonical(block); err != nil {
            // ... (处理错误)
        }
    }
    // ... (设置最终敲定和安全区块)

    if payloadAttributes != nil {
        args := &miner.BuildPayloadArgs{
            Parent:      update.HeadBlockHash,
            Timestamp:   payloadAttributes.Timestamp,
            // ... (其他参数)
        }
        id := args.Id()
        // ...
        payload, err := api.eth.Miner().BuildPayload(args, payloadWitness)
        // ... (处理错误)
        api.localBlocks.put(id, payload) // 将构建好的载荷放入缓存
        return valid(&id), nil
    }
    return valid(nil), nil
}

2.3.4 源码中的 GetPayloadNewPayload

获取构建好的载荷: getPayload

getPayload 的逻辑相对简单。外部暴露的 GetPayloadV* 函数主要负责版本校验,确保 CL 请求的载荷版本是 EL 支持的。

// catalyst/api.go
func (api *ConsensusAPI) GetPayloadV2(payloadID engine.PayloadID) (*engine.ExecutionPayloadEnvelope, error) {
    if !payloadID.Is(engine.PayloadV1, engine.PayloadV2) {
        return nil, engine.UnsupportedFork
    }
    return api.getPayload(payloadID, false)
}

所有版本检查最终都导向内部的 getPayload 函数。其核心任务就是从 api.localBlocks 缓存队列中,根据 payloadId 提取出已经构建完成的区块载荷。如果找不到,则返回 engine.UnknownPayload 错误。

// catalyst/api.go
func (api *ConsensusAPI) getPayload(payloadID engine.PayloadID, full bool) (*engine.ExecutionPayloadEnvelope, error) {
    log.Trace("Engine API request received", "method", "GetPayload", "id", payloadID)
    data := api.localBlocks.get(payloadID, full) // 核心逻辑:从缓存中获取
    if data == nil {
        return nil, engine.UnknownPayload // 如果找不到,返回未知载荷错误
    }
    return data, nil
}

这个流程闭环了区块的构建过程:forkchoiceUpdated 启动构建并缓存结果,getPayload 提取结果。

验证网络传入的载荷: newPayload

newPayload 的逻辑要复杂得多,因为它直接关系到链的安全性和状态一致性。NewPayloadV* 系列函数首先是层层递进的验证关卡,每个版本的函数都会根据对应的硬分叉规则,检查载荷参数的完备性。

// catalyst/api.go
func (api *ConsensusAPI) NewPayloadV3(params engine.ExecutableData, versionedHashes []common.Hash, beaconRoot *common.Hash) (engine.PayloadStatusV1, error) {
    switch {
    case params.Withdrawals == nil:
        return invalidStatus, paramsErr("nil withdrawals post-shanghai")
    case params.ExcessBlobGas == nil:
        return invalidStatus, paramsErr("nil excessBlobGas post-cancun")
    // ...
    }
    return api.newPayload(params, versionedHashes, beaconRoot, nil, false)
}

内部的 newPayload 函数负责真正的执行和验证工作:

  1. 参数校验与反序列化: 外部的 NewPayloadV* 函数根据不同的硬分叉版本对传入的参数进行严格校验。随后,engine.ExecutableDataToBlock 函数将 CL 发来的数据转换为 Geth 内部的 types.Block 对象。

  2. 前置检查:

    • 检查重复: 检查该区块是否已在本地存在。若存在,则直接返回 VALID,避免重复工作。
    • 检查无效祖先: 调用 checkInvalidAncestor 检查该区块是否链接到一个已知的坏块上,如果是,则直接拒绝。
    • 检查父区块: 检查该区块的父区块是否存在于本地。若父区块不存在,则无法处理。此时 EL 会暂时缓存该区块(通过 delayPayloadImport),并返回 ACCEPTED 状态,告知 CL 它已经接受了这个载荷,但需要等待父区块到达后才能继续处理。
  3. 区块插入与验证:

    • 这是最核心的一步。函数调用 api.eth.BlockChain().InsertBlockWithoutSetHead(block, witness)
    • 这个方法会执行区块中的所有交易,并验证最终的状态根、收据根等计算结果是否与区块头中的声明完全一致。
    • 一个关键点是,该方法只负责插入区块并验证,但不会改变当前的规范链头(SetHead)。是否将这个有效区块采纳为新的链头,完全由 CL 在收到 VALID 状态后,根据其自身的共识规则来决定。
  4. 返回状态:

    • 如果区块成功通过所有验证并被插入数据库,函数向 CL 返回 VALID 状态。
    • 如果 InsertBlockWithoutSetHead 在执行或验证过程中失败,源码显示它会捕获错误,并将该区块的哈希添加到 invalidBlocksHitsinvalidTipsets 缓存中。这是一种保护机制,用于追踪坏块并防止节点在短期内重复尝试处理它们。最后,函数向 CL 返回 INVALID 状态。
// catalyst/api.go
func (api *ConsensusAPI) newPayload(...) (engine.PayloadStatusV1, error) {
    // ... (锁定)
    
    // 1. 反序列化
    block, err := engine.ExecutableDataToBlock(params, versionedHashes, beaconRoot, requests)
    // ... (错误处理)
    
    // 2. 检查重复
    if blk := api.eth.BlockChain().GetBlockByHash(params.BlockHash); blk != nil {
        // ...
        hash := blk.Hash()
        return engine.PayloadStatusV1{Status: engine.VALID, LatestValidHash: &hash}, nil
    }

    // 3. 父区块检查
    parent := api.eth.BlockChain().GetBlock(block.ParentHash(), block.NumberU64()-1)
    if parent == nil {
        // 返回 ACCEPTED (通过 delayPayloadImport)
        return api.delayPayloadImport(block), nil
    }
    // ... (其他验证,如时间戳)

    // 4. 区块插入与验证
    _, err = api.eth.BlockChain().InsertBlockWithoutSetHead(block, witness)
    if err != nil {
        // ... (处理错误,标记为无效区块)
        return api.invalid(err, parent.Header()), nil
    }

    // 5. 返回成功状态
    hash := block.Hash()
    return engine.PayloadStatusV1{Status: engine.VALID, LatestValidHash: &hash}, nil
}