レッスン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_payloadはPayloadIdを即返し、engine は後で別 task(別スレッドかも)でpayload_ready(id)を呼ぶ。この 2 つの独立した protocol moment を糊付けする seam が必要。
ステップで組み立てる
Step 1: root Cargo.toml に reth-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.toml(eyre を 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_number → Ok(Some(n)) 続行 / Ok(None) → Rejected(親拒否で consensus 接続が安全に)/ Err → Internal)② 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.rs(production-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」を誠実に証明する(片方では不十分)。