FABRKNT
Step 1. Consensus — cargo init から single-validator devnet 構築
Live Reth
レッスン 15 / 16·CONTENT50 分90 XP
コース
Step 1. Consensus — cargo init から single-validator devnet 構築
レッスンの役割
CONTENT
順序
15 / 16

レッスン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().awaitOk を返すことだけ assert し、Reth の canonical head が動いたことは assert しないのか? ヒント: build_payload の出力(header だけ、tx/receipt/state-root なし)に何が欠けているか。engine_newPayload を先に送らない限り fork_choice_updatedSYNCING(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-primitivesEthEngineTypes = Ethereum mainnet engine surface の type bundle)/ alloy-rpc-types-engineForkchoiceState、CL→EL の canonical wire payload)。alloy-rpc-types-engine2.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_handleOption にするのは、全 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_handleconst 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().awaitOk を返すこと(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 つの ConsensusBridge method 全てが real Reth コードパスに到達 — bridge のループが閉じた。