レッスン14 — commit が Reth の Engine API forkchoice を駆動する
問い
commit で「ローカル bookkeeping」と「Reth engine への通知」のどちらを先にやるべきか? そして engine 呼び出しが失敗したら、確定済みの commit を rollback すべきか?
原理(最小モデル)
- local-first、engine-second。 bridge の
chain: HashMapが consensus 層の source of truth。local を先に commit すれば、engine 呼び出しが失敗しても rollback 不要(consensus commit の rollback は safety 違反)。一般化: primary store 先、secondary index/replica 後。 Option<EngineHandle>は test ergonomics。 必須にすると全 unit test が実 node を bootstrap する羽目に。Optionなら test はNone(local path のみ)、integration はSome(handle)(両 path)。- engine 応答は意図的に破棄。 マッチする
engine_newPayloadを先に送っていないのでSYNCINGが正解応答。これを error 扱いすると全 caller が「L14 は部分統合」を知る羽目に。破棄すれば API が正直:「local commit 完了、下流通知は best-effort」。 - 3-field
ForkchoiceStateを v0 では同一 hash。 mainnet は head/safe/finalized を区別するが、v0 single-validator は全 commit が final → 3 つとも同じ hash(multi-validator への forward-compat で形は保つ)。
具体例
commit の 2 phase の時間順:
Phase 1(絶対成功): lock → pending から header lookup → chain.insert(hash) → head = Some(hash)
※ ここを抜けた瞬間 consensus 上「commit 済み」確定。下流が即この値を読む。
│ (もう引き返せない)
Phase 2(best-effort): if let Some(handle) { ForkchoiceState{head=safe=finalized=hash}
→ let _ = handle.fork_choice_updated(state, None).await } ← 応答は破棄
▼
Reth engine(body 未送なので SYNCING 応答)→ commit は Ok(()) を返す
これで 4 method 全て(build/payload_ready/validate/commit)が real Reth コードパスに到達 — ループが閉じる。
失敗例(誤解)
「engine を先に送って成功を確認してから local commit」は誤り — engine 失敗時に確定済み consensus commit を rollback する判断を迫られ、safety 違反になる。「engine が INVALID を返したら Err を返す」も誤り — Malachite に「その decided block は存在しない」と告げ chain が壊れる(Reth の chain view は consensus の 下流、逆ではない)。
ここまでで「local-first・best-effort 副作用」は着地した。ここから live_node.rs を改修する。コードは完全形。
🛑 予測。 なぜテストは
commit().awaitがOkを返すことだけ assert し、Reth の canonical head が動いたことは assert しないのか? ヒント:build_payloadの出力(header だけ、tx/receipt/state-root なし)に何が欠けているか。engine_newPayloadを先に送らない限りfork_choice_updatedはSYNCING(body を知らない)を返す。L14 が証明するのは wire 接続。payload execution は将来コース。
ステップで組み立てる
Step 1: root Cargo.toml に 2 dep
reth-ethereum-engine-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
alloy-rpc-types-engine = { version = "2.0", default-features = false }
reth-ethereum-engine-primitives(EthEngineTypes = Ethereum mainnet engine surface の type bundle)/ alloy-rpc-types-engine(ForkchoiceState、CL→EL の canonical wire payload)。alloy-rpc-types-engine を 2.0 に pin して Reth v2.2.0 の 2.0.4 と一致させる(不一致だと ForkchoiceState が別型になり handle が拒否)。
Step 2: crates/evm/Cargo.toml(3 dep 追加)
reth-engine-primitives = { workspace = true } # NEW: ConsensusEngineHandle
reth-ethereum-engine-primitives = { workspace = true } # NEW: EthEngineTypes
alloy-rpc-types-engine = { workspace = true } # NEW: ForkchoiceState
reth-engine-primitives は L1 から workspace dep だった(ConsensusEngineHandle が住む)— ここで「利用可能」から「import する」へ昇格。
Step 3: live_node.rs imports + struct(+engine_handle)
use alloy_consensus::Header;
use alloy_primitives::{Address, B256};
use alloy_rpc_types_engine::ForkchoiceState; // NEW
use async_trait::async_trait;
use openhl_consensus::bridge::{BridgeError, ConsensusBridge};
use openhl_types::{BlockHash, ExecutedBlock, PayloadAttrs, PayloadId, PayloadStatus};
use reth_chainspec::{ChainSpec, EthChainSpec};
use reth_consensus::HeaderValidator;
use reth_engine_primitives::ConsensusEngineHandle; // NEW
use reth_ethereum_consensus::EthBeaconConsensus;
use reth_ethereum_engine_primitives::EthEngineTypes; // NEW
use reth_primitives_traits::SealedHeader;
use reth_storage_api::{BlockNumReader, HeaderProvider};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Debug)]
pub struct LiveRethEvmBridge<P> {
provider: P,
chain_spec: Arc<ChainSpec>,
validator: EthBeaconConsensus<ChainSpec>,
/// Optional in-process Engine API handle. When installed via
/// [`Self::with_engine_handle`], `commit` sends a `ForkchoiceUpdated`
/// to Reth so its canonical chain advances in lockstep with consensus.
/// `None` at v0 means commits stay local to the bridge's `state.chain`
/// `HashMap` — fine for unit tests, but RPC clients won't see new heads.
engine_handle: Option<ConsensusEngineHandle<EthEngineTypes>>, // NEW
state: Mutex<State>,
}
engine_handle を Option にするのは、全 consumer が Reth を bootstrap する production node ではないから — unit test は provider 相手の bridge だけ欲しい。型レベルの optionality が漏れる API surface を避ける。
Step 4: new() + builder メソッド
impl<P> LiveRethEvmBridge<P> {
#[must_use]
pub fn new(provider: P, chain_spec: Arc<ChainSpec>) -> Self {
let validator = EthBeaconConsensus::new(Arc::clone(&chain_spec));
Self {
provider,
chain_spec,
validator,
engine_handle: None, // NEW
state: Mutex::new(State::default()),
}
}
/// Install a Reth in-process Engine API handle. After this call,
/// `commit` will fire a `ForkchoiceUpdated` to Reth's consensus engine
/// alongside its own local bookkeeping. Without an engine handle, the
/// bridge still works (commits go to its internal `HashMap`) but Reth's
/// canonical chain won't advance — RPC and any other Reth consumer will
/// see only the genesis block.
#[must_use]
pub fn with_engine_handle(
mut self,
handle: ConsensusEngineHandle<EthEngineTypes>,
) -> Self {
self.engine_handle = Some(handle);
self
}
#[must_use]
pub const fn has_engine_handle(&self) -> bool {
self.engine_handle.is_some()
}
#[must_use]
pub fn chain_spec(&self) -> &Arc<ChainSpec> {
&self.chain_spec
}
}
with_engine_handle は consume-and-return-self builder(mut self、所有権を取り return)。#[must_use] — return を bind し忘れると修正済み bridge が drop される。self を消費するので let bridge = ...new(...).with_engine_handle(h); と 1 式でチェーンする(2 行に分けると move out される)。has_engine_handle は const fn accessor。
Step 5: commit を rewrite — local 先、engine best-effort
async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError> {
let hash = B256::from(block_hash.0);
// Local bookkeeping first. If this fails, we never call the engine
// — the bridge stays in a consistent state.
let _header = {
let mut s = self.state.lock().expect("state mutex poisoned");
let header = s
.pending
.values()
.find(|(h, _)| *h == hash)
.map(|(_, h)| h.clone())
.ok_or_else(|| {
BridgeError::Rejected(format!("commit for unknown hash {hash}"))
})?;
s.chain.insert(hash, header.clone());
s.head = Some(hash);
header
};
// Best-effort: if an Engine API handle has been installed, also tell
// Reth's consensus engine about the new canonical head. We always
// commit *locally* first (above) — sending to the engine is best-
// effort at this stage because we haven't yet uploaded a real
// ExecutionPayload via newPayload, so the engine will return
// SYNCING/INVALID. The wire being connected is what 7d proves; full
// payload-execution alignment is downstream once fills become EVM
// transactions.
if let Some(handle) = &self.engine_handle {
let state = ForkchoiceState {
head_block_hash: hash,
safe_block_hash: hash,
finalized_block_hash: hash,
};
let _ = handle.fork_choice_updated(state, None).await;
}
Ok(())
}
Phase 1: local bookkeeping(L13 と同じ shape、header 欠落で Rejected、? が engine 前に exit)。Phase 2: engine_handle.is_some() のときだけ 3 スロット同一 hash の ForkchoiceState を fire。let _ = ...await で engine 応答(VALID/SYNCING/INVALID)を破棄 — INVALID で rollback しない(local state が source of truth、Reth はその下流)。
Step 6: integration test を追加(既存テストは変えず別テストで isolate)
/// **Stage 7d**: with a Reth `ConsensusEngineHandle` installed, `commit`
/// sends a `ForkchoiceUpdated` to the in-process Engine API. The bridge's
/// own bookkeeping still happens (so existing callers don't regress), but
/// now Reth is told about the new head too.
///
/// At this stage the engine will respond SYNCING because we haven't sent
/// a matching `newPayload` (`build_payload` doesn't yet produce a real
/// `ExecutionPayload`). That's intentional: レッスン 14 proves the wire is
/// connected. Full alignment between Malachite's commit and Reth's
/// canonical head needs `newPayload` integration, which is the next
/// staging chunk after fills become EVM transactions.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn commit_sends_forkchoice_to_engine_when_handle_installed() {
use reth_node_ethereum::node::EthereumAddOns;
let runtime = Runtime::test();
let chain_spec = dev_chain_spec();
let node_config = NodeConfig::test().dev().with_chain(chain_spec.clone());
// We need add_ons_handle for the engine handle — use the explicit
// NodeBuilder path with EthereumAddOns rather than launch_with_dbg.
let handle = NodeBuilder::new(node_config)
.testing_node(runtime)
.with_types::<EthereumNode>()
.with_components(EthereumNode::components())
.with_add_ons(EthereumAddOns::default())
.launch()
.await
.expect("launch failed");
// Pull the engine handle out of add_ons. This is what RPC's
// engine_forkchoiceUpdated endpoint would dispatch to — we're
// taking the in-process shortcut around the JSON-RPC layer.
let engine_handle = handle.node.add_ons_handle.beacon_engine_handle.clone();
let bridge = LiveRethEvmBridge::new(handle.node.provider.clone(), chain_spec)
.with_engine_handle(engine_handle);
assert!(
bridge.has_engine_handle(),
"with_engine_handle must install the handle"
);
let genesis_hash_b256 = handle
.node
.provider
.block_hash(0)
.expect("provider call failed")
.expect("provider has no genesis");
// Build a payload on top of genesis so commit has something to find.
let attrs = PayloadAttrs {
timestamp: 1,
fee_recipient: [0u8; 20],
prev_randao: [0u8; 32],
};
let id = bridge
.build_payload(BlockHash(genesis_hash_b256.0), attrs)
.await
.expect("build_payload failed");
let block = bridge.payload_ready(id).await.expect("payload_ready failed");
// The actual test: commit should not panic, not block forever, not
// surface an error from the engine-side SYNCING response. We're
// proving the wire is connected — that fork_choice_updated reaches
// the engine and returns *some* response (even SYNCING).
bridge
.commit(block.hash)
.await
.expect("commit failed even though local bookkeeping should succeed");
// Negative case: a commit for an unknown hash must still be Rejected
// (the engine-side call doesn't happen because the bridge bails out
// before it).
let bogus = BlockHash([0xddu8; 32]);
let err = bridge.commit(bogus).await.unwrap_err();
assert!(
matches!(err, BridgeError::Rejected(_)),
"unknown hash must yield Rejected"
);
drop(handle);
}
要点: with_types().with_components().with_add_ons().launch() の明示的 builder で add_ons_handle を expose(launch_with_debug_capabilities は expose しない)。add_ons_handle.beacon_engine_handle(外部 CL が JSON-RPC で叩く engine API の in-process ショートカット、Arc ベースで clone 安価)。メインの assertion は commit().await が Ok を返すこと(engine の SYNCING 応答は破棄)。unknown hash は engine path 前に bail して Rejected を維持。末尾 drop(handle) で background task を tear down。
答え合わせ
cd ~/code/openhl-reference && git checkout 0cac571
diff -u ~/code/my-openhl/crates/evm/src/live_node.rs ./crates/evm/src/live_node.rs
git checkout main
0cac571 には Stage 8(CLOB integration)由来の追加コードが含まれることがある。Stage 7d 固有の変更(engine_handle フィールド / with_engine_handle() / commit body 再構成 / engine test)は厳密一致するべき。
合格基準
cargo test -p openhl-evm commit_sends_forkchoice_to_engine_when_handle_installed --release
→ 1 個 pass(~3 秒)。これで 4 method 全てが real Reth に到達。よくあるミス: with_engine_handle の return を bind し忘れ(has_engine_handle が false)/ .await を落として silent skip(hang でなく通知スキップ)/ reth-* の SHA drift で EthereumAddOns が見つからない / commit の ? が engine 前に exit せず unknown hash で Ok を返す。
まとめ(3行)
- commit は local bookkeeping を先に確定(source of truth)し、engine 通知は best-effort の副作用 — Phase 2 失敗で Phase 1 を rollback しない(safety)。
Option<EngineHandle>で同じ struct が test(None)と production(Some)に仕える。engine 応答(今はSYNCING)は意図的に破棄。- 4 つの
ConsensusBridgemethod 全てが real Reth コードパスに到達 — bridge のループが閉じた。