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

レッスン12 — LiveRethEvmBridge が real chain から parent を読む

問い

bridge を live Reth provider に接続して、parent block を real chain から読むには? そして bridge が「インメモリ合成に fallback せず本当に Reth と対話している」ことをどう証明するか?

原理(最小モデル)

  • P: BlockNumReader に generic にする(具象 BlockchainProvider でなく)。 bridge が必要とする capability をちょうど 1 つだけ宣言。具象 provider は 30+ trait bound を背負い、それを全 caller に流すのは負担。generic は surface を絞り mock test も自明にする。
  • Result<Option<u64>> が運用エラーとプロトコルエラーを区別。 DB call 失敗 → BridgeError::Internal(アラート)、未知 hash → BridgeError::Rejected(nil 投票して進む)。
  • 未知の親の拒否は安全性プロパティ。 live chain が見たことない hash の上に build しろと言われたら拒否 — 悪意ある proposer が EL を fork subtree に誘導するのを防ぐ。
  • 2 bridge は integration の 2 段階。 RethEvmBridge(L5、alloy のみ)と LiveRethEvmBridge(L12、live provider)は重複でなく統合の段階を表す。

具体例

provider 抽象境界(L5 の trait boundary に 1 段追加):

BlockHash (contract) ──変換──► B256 (alloy) ──► [★ BlockNumReader trait 境界 ★]
                                                       │ 具象を隠蔽
                                                       ▼
                                            BlockchainProvider ──► MDBX ──► u64

LiveRethEvmBridge<P> 本体は具象 provider 型を一切知らない。production は live provider、test は mock を同じ IF で差し替え可能。

失敗例(誤解)

「happy path だけテストすればいい」は誤り — bridge が偶然インメモリ state に fallback して任意の親に child を作るバグを見逃す(コンパイル通る・happy pass・consensus が壊れた高さを commit)。「具象 BlockchainProvider を直接取る」も誤り — 全 consumer が 30+ bound を糸通しする羽目に。


ここまでで「provider generic・2 段 Result・親拒否」は着地した。ここから live_node.rs を組み立てる。コードは完全形。

🛑 予測。 build_payload が live provider から読むのに、なぜ LiveRethEvmBridge は依然 pending/chain/head を持つ Mutex<State> を保持するのか? ヒント: build_payloadPayloadId を即返し、engine は後で別 task(別スレッドかも)で payload_ready(id) を呼ぶ。この 2 つの独立した protocol moment を糊付けする seam が必要。

ステップで組み立てる

Step 1: root Cargo.tomlreth-storage-api

reth-storage-api          = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }

BlockNumReader / BlockHashReader reader trait が住む。他の reth-* と同じ pinned SHA(version skew があると node.provider を受け入れられない)。

Step 2: crates/evm/Cargo.tomleyre を dev → production へ昇格 + reth-storage-api

[dependencies]
openhl-consensus         = { workspace = true }
openhl-types             = { workspace = true }
async-trait              = { workspace = true }
eyre                     = { workspace = true }      # dev → production(BridgeError::Internal を build_payload で構築)
alloy-primitives         = { workspace = true }
alloy-consensus          = { workspace = true }
reth-ethereum-primitives = { workspace = true }
reth-storage-api         = { workspace = true }      # NEW

eyre が production になるのは BridgeError::Internal(eyre::eyre!(...))build_payload(production コード)で構築するから。

Step 3: crates/evm/src/live_node.rs — doc + imports + struct

//! `LiveRethEvmBridge` — `ConsensusBridge` backed by a real Reth provider.
//!
//! Stage 7b: parent lookups go through the live node's provider via the
//! `BlockNumReader` trait, so `build_payload` produces a child block whose
//! `number` and `parent_hash` reflect actual chain state rather than the
//! in-process synthesis of [`crate::engine::RethEvmBridge`].
//!
//! Still stubbed for now (each rolls into a later stage):
//!   - `validate_payload` → Stage 7c: real `BlockExecutor` execution
//!   - `commit` → Stage 7d: forkchoice via in-process Engine API
//!
//! Both stubs are visible markers of "what still needs the live node."

use alloy_consensus::Header;
use alloy_primitives::{Address, B256};
use async_trait::async_trait;
use openhl_consensus::bridge::{BridgeError, ConsensusBridge};
use openhl_types::{BlockHash, ExecutedBlock, PayloadAttrs, PayloadId, PayloadStatus};
use reth_storage_api::BlockNumReader;
use std::collections::HashMap;
use std::sync::Mutex;

#[derive(Debug)]
pub struct LiveRethEvmBridge<P> {
    provider: P,
    state: Mutex<State>,
}

#[derive(Debug, Default)]
struct State {
    next_payload_id: u64,
    pending: HashMap<u64, (B256, Header)>,
    chain: HashMap<B256, Header>,
    head: Option<B256>,
}

impl<P> LiveRethEvmBridge<P> {
    #[must_use]
    pub fn new(provider: P) -> Self {
        Self {
            provider,
            state: Mutex::new(State::default()),
        }
    }
}

provider を State の外に置くのは、BlockNumReader 実装が Sync + Clone で多数の async task が同時共有する前提だから(mutex に入れると全 block_number lookup が直列化)。lock は変更されるものを守り、読まれるものは守らない。

Step 4: build_payload が live read

#[async_trait]
impl<P> ConsensusBridge for LiveRethEvmBridge<P>
where
    P: BlockNumReader + Clone + Sync + 'static,
{
    async fn build_payload(
        &self,
        parent: BlockHash,
        attrs: PayloadAttrs,
    ) -> Result<PayloadId, BridgeError> {
        let parent_b256 = B256::from(parent.0);

        // LIVE READ: parent's block number comes from the real provider, not
        // an in-process HashMap. If the provider doesn't know this hash, we
        // refuse to build a child on it.
        let parent_number = self
            .provider
            .block_number(parent_b256)
            .map_err(|e| BridgeError::Internal(eyre::eyre!("provider error: {e}")))?
            .ok_or_else(|| {
                BridgeError::Rejected(format!("provider has no block with hash {parent_b256}"))
            })?;

        let mut s = self.state.lock().expect("state mutex poisoned");
        let id = s.next_payload_id;
        s.next_payload_id += 1;

        let header = Header {
            parent_hash: parent_b256,
            number: parent_number + 1,
            timestamp: attrs.timestamp,
            beneficiary: Address::from(attrs.fee_recipient),
            mix_hash: B256::from(attrs.prev_randao),
            ..Default::default()
        };
        let hash = header.hash_slow();
        s.pending.insert(id, (hash, header));
        Ok(PayloadId(id))
    }

trait bound P: BlockNumReader + Clone + Sync + 'static が契約。3 フェーズ: ① live read(block_numberOk(Some(n)) 続行 / Ok(None)Rejected親拒否で consensus 接続が安全に)/ ErrInternal)② state allocation(lock 下に I/O なし)③ header 合成(number = parent_number + 1 は live read 由来)。2 段 Result<Option<u64>> は「hash 欠落=プロトコル問題」と「provider crash=運用問題」を別 variant に分ける。

Step 5: payload_ready / validate_payload / commit(後者 2 つは stub)

    async fn payload_ready(&self, id: PayloadId) -> Result<ExecutedBlock, BridgeError> {
        let s = self.state.lock().expect("state mutex poisoned");
        let n = id.0;
        let (hash, header) = s
            .pending
            .get(&n)
            .cloned()
            .ok_or_else(|| BridgeError::Rejected(format!("unknown payload id {n}")))?;
        Ok(ExecutedBlock {
            hash: BlockHash(hash.0),
            parent_hash: BlockHash(header.parent_hash.0),
            number: header.number,
            state_root: header.state_root.0,
        })
    }

    async fn validate_payload(
        &self,
        _block: &ExecutedBlock,
    ) -> Result<PayloadStatus, BridgeError> {
        // Stage 7c: replace with real BlockExecutor execution + state-root check.
        Ok(PayloadStatus::Valid)
    }

    async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError> {
        // Stage 7d: replace with in-process Engine API forkchoice update.
        let hash = B256::from(block_hash.0);
        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);
        s.head = Some(hash);
        Ok(())
    }
}

validate_payload / commit は L4 と同じ shape の stub。コメントが real execution(L13 / Stage 7c)と forkchoice(L14 / Stage 7d)の場所を名指し — visible な stub は技術負債でなく進捗マーカー

Step 6: lib.rsproduction-visible#[cfg(test)] でない)

pub mod bridges;
pub mod live_node;

#[cfg(test)]
mod reth_node;

L11 の bootstrap は genuine に test-only だったが、L12 の bridge は production API(L13–15 で main から使う)。

Step 7: integration test(happy + negative)

#[cfg(test)]
mod tests {
    use super::*;
    use alloy_genesis::Genesis;
    use reth_chainspec::ChainSpec;
    use reth_node_builder::{NodeBuilder, NodeHandle};
    use reth_node_core::node_config::NodeConfig;
    use reth_node_ethereum::EthereumNode;
    use reth_storage_api::BlockHashReader;
    use reth_tasks::Runtime;
    use std::sync::Arc;

    fn dev_chain_spec() -> Arc<ChainSpec> {
        let custom_genesis = r#"{
            "nonce": "0x42",
            "timestamp": "0x0",
            "extraData": "0x5343",
            "gasLimit": "0x5208",
            "difficulty": "0x400000000",
            "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
            "coinbase": "0x0000000000000000000000000000000000000000",
            "alloc": {},
            "number": "0x0",
            "gasUsed": "0x0",
            "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
            "config": {
                "ethash": {},
                "chainId": 2600,
                "homesteadBlock": 0,
                "eip150Block": 0,
                "eip155Block": 0,
                "eip158Block": 0,
                "byzantiumBlock": 0,
                "constantinopleBlock": 0,
                "petersburgBlock": 0,
                "istanbulBlock": 0,
                "berlinBlock": 0,
                "londonBlock": 0,
                "terminalTotalDifficulty": 0,
                "terminalTotalDifficultyPassed": true,
                "shanghaiTime": 0
            }
        }"#;
        let genesis: Genesis = serde_json::from_str(custom_genesis).expect("dev genesis parses");
        Arc::new(genesis.into())
    }

    /// END-TO-END Stage 7b: bootstrap a real Reth node, hand its provider to
    /// `LiveRethEvmBridge`, build a payload on top of the real genesis block.
    /// Asserts the `parent_hash` and number come from the live chain, not an
    /// in-process synthesis.
    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
    async fn live_bridge_builds_on_real_genesis() {
        let runtime = Runtime::test();
        let chain_spec = dev_chain_spec();
        let node_config = NodeConfig::test().dev().with_chain(chain_spec);

        let NodeHandle {
            node,
            node_exit_future: _,
        } = NodeBuilder::new(node_config)
            .testing_node(runtime)
            .node(EthereumNode::default())
            .launch_with_debug_capabilities()
            .await
            .expect("launch failed");

        // Pull the genesis hash from the live provider.
        let genesis_hash_b256 = node
            .provider
            .block_hash(0)
            .expect("provider call failed")
            .expect("provider has no block 0 (genesis)");

        // Construct the bridge against the live provider.
        let bridge = LiveRethEvmBridge::new(node.provider.clone());

        // Build a payload on the real genesis.
        let attrs = PayloadAttrs {
            timestamp: 1,
            fee_recipient: [0u8; 20],
            prev_randao: [0u8; 32],
        };
        let id = bridge
            .build_payload(BlockHash(genesis_hash_b256.0), attrs.clone())
            .await
            .expect("build_payload failed");
        let block = bridge.payload_ready(id).await.expect("payload_ready failed");

        // The bridge's lookup hit the LIVE provider — assert the resulting
        // header carries genesis as its parent and is at height 1.
        assert_eq!(block.parent_hash, BlockHash(genesis_hash_b256.0));
        assert_eq!(block.number, 1);

        // Negative case: a fabricated parent hash must be rejected because
        // the live provider doesn't know it.
        let fake_parent = BlockHash([0xeeu8; 32]);
        let err = bridge.build_payload(fake_parent, attrs).await.unwrap_err();
        assert!(matches!(err, BridgeError::Rejected(_)));
    }
}

happy path: live genesis hash の上に build → parent_hash == genesis かつ number == 1(インメモリ合成なら number は任意になりえた。1 が出るのは block_number(genesis_hash)Some(0) を返したときだけ = live read の証明)。negative path: [0xee;32] は chain が知らない → BridgeError::Rejected。両方が load-bearing(片方では fallback バグ / 常時 reject バグを見逃す)。

答え合わせ

cd ~/code/openhl-reference && git checkout 8d211b8
diff -u ~/code/my-openhl/crates/evm/src/live_node.rs ./crates/evm/src/live_node.rs
git checkout main

8d211b8 に ~227 行の live_node.rs。trait bound・build_payload 本体・2 パステストは厳密一致するべき。

合格基準

cargo test -p openhl-evm live_bridge_builds_on_real_genesis --release

live_bridge_builds_on_real_genesis 1 個 pass(happy + negative、~2.4 秒)。よくあるミス: reth-storage-api の SHA 不一致 / test の [dev-dependencies]reth-provider 追加忘れ / .ok_or_else(|| Rejected).expect にして negative path が発火しない。

まとめ(3行)

  • bridge は P: BlockNumReader に generic — 具象 provider の 30+ bound を避け、production live / test mock を同じ IF で差し替え。
  • build_payload の live read(block_number)が child の number/parent を real chain から得る。未知の親は Rejected
  • happy + negative の 2 テストが「live read が load-bearing」を誠実に証明する(片方では不十分)。