レッスン13 — validate_payload が Reth の EthBeaconConsensus を走らせる
問い
自分が build_payload で作った block を、自分の validate_payload が拒否しないことをどう保証するか? つまり build と validate が 同じルール を使うことをどう担保するか?
原理(最小モデル)
- builder と validator が source of truth を共有する。
ChainSpec::next_block_base_feeを builder が base fee 計算に使い、EthBeaconConsensusが検証に使うのも同じヘルパー。数式の重複なし、hardfork 越しの drift リスクなし。 - validator の reject は通常動作(crash でない)。 「malformed だ」は
PayloadStatus::InvalidであってErrではない。error→status の map で engine は走り続け次の proposal を選べる。DB エラーだけBridgeError::Internalにエスカレート。 - trait bound は段階的に広がる。 L12 は
BlockNumReader、L13 は+ HeaderProvider。trait bound は spec — bridge が Reth surface のどこを要求するかをそのまま文書化する。 SealedHeaderは hash を cache する。Header+ 事前計算B256を wrap し、.hash()のたびに 500 byte を Keccak し直すのを避ける。
具体例
build 側と validate 側が同じ Arc<ChainSpec> を握る:
Arc<ChainSpec>(chainId / hardforks / EIP-1559 params)
┌──────────────┴──────────────┐
▼ ▼
build_payload EthBeaconConsensus.validate_header_against_parent
next_block_base_fee() ├─ base_fee check(同じ next_block_base_fee)
gas_limit = parent.gas_limit ├─ gas_limit drift ±1/1024
difficulty = ZERO ├─ timestamp monotonicity
timestamp 単調化 └─ post-merge invariants → Valid ✅
両側が同じ instance を握るので、Cancun 以降の base_fee 公式変更でも「片方だけ古い」が物理的に起きない。
失敗例(誤解)
「build 側で EIP-1559 数式を inline 実装すればいい」は誤り — hardfork(Cancun は BASE_FEE_MAX_CHANGE_DENOMINATOR 変更)で公式がずれ、自分の生成した block を自分の validator が拒否する silent fork が量産される。「validator の Err を Internal にマップ」も誤り — engine app loop を kill してしまう。
ここまでで「共有 source-of-truth・reject は通常動作」は着地した。ここから live_node.rs を改修する。コードは完全形。
🛑 予測。
validate_header_against_parentは parent の full sealed header(gas_limit/timestamp/base_fee 全部)を要求するのに、なぜblock_numberはu64だけ返すのか? ヒント: validator の 4 sub-check(number/timestamp/gas-limit drift/EIP-1559 base fee)はそれぞれ parent の何を要るか。だから L13 で bound をBlockNumReaderに 加えてHeaderProviderまで拡張する。
ステップで組み立てる
Step 1: root Cargo.toml に 3 dep
reth-consensus = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-ethereum-consensus = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-primitives-traits = "0.3"
reth-consensus(HeaderValidator trait)/ reth-ethereum-consensus(具象 EthBeaconConsensus)/ reth-primitives-traits(SealedHeader)。後者だけ crates.io(git でない) — Reth の中で外部エコシステムに stabilize された部分で、crates.io publish パッケージは git-rev 依存を含められない(Cargo の制約)。
Step 2: crates/evm/Cargo.toml(reth-chainspec を dev→production 昇格 + 3 dep)
[dependencies]
openhl-consensus = { workspace = true }
openhl-types = { workspace = true }
async-trait = { workspace = true }
eyre = { workspace = true }
alloy-primitives = { workspace = true }
alloy-consensus = { workspace = true }
reth-ethereum-primitives = { workspace = true }
reth-storage-api = { workspace = true }
reth-consensus = { workspace = true } # NEW
reth-ethereum-consensus = { workspace = true } # NEW
reth-primitives-traits = { workspace = true } # NEW
reth-chainspec = { workspace = true } # NEW(dev → production)
reth-chainspec が production になるのは bridge が Arc<ChainSpec> を struct に保持するから。
Step 3: live_node.rs imports + struct(+2 フィールド)+ new()
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_chainspec::{ChainSpec, EthChainSpec}; // NEW
use reth_consensus::HeaderValidator; // NEW
use reth_ethereum_consensus::EthBeaconConsensus; // NEW
use reth_primitives_traits::SealedHeader; // NEW
use reth_storage_api::{BlockNumReader, HeaderProvider}; // CHANGED: + HeaderProvider
use std::collections::HashMap;
use std::sync::{Arc, Mutex}; // CHANGED: + Arc
#[derive(Debug)]
pub struct LiveRethEvmBridge<P> {
provider: P,
chain_spec: Arc<ChainSpec>, // NEW
validator: EthBeaconConsensus<ChainSpec>, // NEW
state: Mutex<State>,
}
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,
state: Mutex::new(State::default()),
}
}
#[must_use]
pub fn chain_spec(&self) -> &Arc<ChainSpec> {
&self.chain_spec
}
}
State は L12 のまま不変。EthChainSpec は ChainSpec に next_block_base_fee を与える拡張 trait。
Step 4: trait bound を拡張
#[async_trait]
impl<P> ConsensusBridge for LiveRethEvmBridge<P>
where
P: BlockNumReader + HeaderProvider<Header = Header> + Clone + Sync + 'static,
{
HeaderProvider<Header = Header> で provider が full Header を serve する(associated type binding で「provider の Header はこちらの alloy Header」と固定)。BlockNumReader は今や冗長だが L12→L13 の進行を文書化するため残す。
Step 5: build_payload を production grade に
async fn build_payload(
&self,
parent: BlockHash,
attrs: PayloadAttrs,
) -> Result<PayloadId, BridgeError> {
let parent_b256 = B256::from(parent.0);
// LIVE READ: pull the parent's full sealed header from the real
// provider so we can copy fields that EthBeaconConsensus will check
// against during validate_payload (gas_limit drift, EIP-1559 base
// fee, difficulty=0 post-merge).
let parent_sealed = self
.provider
.sealed_header_by_hash(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 parent_header = parent_sealed.header();
let mut s = self.state.lock().expect("state mutex poisoned");
let id = s.next_payload_id;
s.next_payload_id += 1;
let our_timestamp = attrs.timestamp.max(parent_header.timestamp + 1);
// Compute the EIP-1559 base fee for our block via the chain spec —
// identical math to what EthBeaconConsensus's
// `validate_against_parent_eip1559_base_fee` will check against.
let next_base_fee = self
.chain_spec
.next_block_base_fee(parent_header, our_timestamp);
let header = Header {
parent_hash: parent_b256,
number: parent_header.number + 1,
// Timestamp must be strictly greater than parent's; force at least
// parent.timestamp + 1 even if attrs.timestamp came in stale.
timestamp: our_timestamp,
beneficiary: Address::from(attrs.fee_recipient),
mix_hash: B256::from(attrs.prev_randao),
// Keep gas_limit identical to parent so EthBeaconConsensus's
// 1/1024 drift check passes trivially. A real payload builder
// would tune this per network policy.
gas_limit: parent_header.gas_limit,
// Post-merge: difficulty must be 0.
difficulty: alloy_primitives::U256::ZERO,
base_fee_per_gas: next_base_fee,
..Default::default()
};
let hash = header.hash_slow();
s.pending.insert(id, (hash, header));
Ok(PayloadId(id))
}
L12 からの変更: sealed_header_by_hash(full header が要る)/ our_timestamp = attrs.timestamp.max(parent.timestamp + 1)(厳密単調)/ 慎重な 4 フィールド(gas_limit コピー=drift 自明 / difficulty ZERO=post-merge / base_fee は validator と同じ next_block_base_fee / 残り default)。
Step 6: validate_payload を rewrite
async fn validate_payload(
&self,
block: &ExecutedBlock,
) -> Result<PayloadStatus, BridgeError> {
let block_hash = B256::from(block.hash.0);
let parent_hash = B256::from(block.parent_hash.0);
// Find our header for this block. In single-validator mode we always
// built it, so it sits in pending (pre-commit) or chain (post-commit).
let header = {
let s = self.state.lock().expect("state mutex poisoned");
s.pending
.values()
.find(|(h, _)| *h == block_hash)
.map(|(_, h)| h.clone())
.or_else(|| s.chain.get(&block_hash).cloned())
};
let Some(header) = header else {
return Ok(PayloadStatus::Invalid);
};
// Fetch parent sealed header from the LIVE provider.
let Some(parent_sealed) = self
.provider
.sealed_header_by_hash(parent_hash)
.map_err(|e| BridgeError::Internal(eyre::eyre!("provider error: {e}")))?
else {
return Ok(PayloadStatus::Invalid);
};
// Run Reth's real header validator. EthBeaconConsensus checks number
// monotonicity, timestamp monotonicity, gas-limit drift, base-fee.
let our_sealed = SealedHeader::new(header, block_hash);
match self
.validator
.validate_header_against_parent(&our_sealed, &parent_sealed)
{
Ok(()) => Ok(PayloadStatus::Valid),
Err(_) => Ok(PayloadStatus::Invalid),
}
}
4 フェーズ: ① header lookup(pending/chain、なければ Invalid)② parent を live provider から fetch(なければ Invalid、provider error は Internal)③ SealedHeader::new で wrap ④ validate_header_against_parent を走らせ Ok(())→Valid / Err(_)→Invalid。Reth 内部の 4 sub-check(hash_number / timestamp / gas_limit drift / eip1559 base_fee)。Err を Invalid にマップするのは validation 失敗が protocol シグナルで運用失敗でないから(Internal は engine を kill する)。commit は L12 のまま stub(L14 で forkchoice に置換)。
Step 7: テストに 2 assertion 追加
#[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.clone());
let NodeHandle {
node,
node_exit_future: _,
} = NodeBuilder::new(node_config)
.testing_node(runtime)
.node(EthereumNode::default())
.launch_with_debug_capabilities()
.await
.expect("launch failed");
let genesis_hash_b256 = node
.provider
.block_hash(0)
.expect("provider call failed")
.expect("provider has no block 0 (genesis)");
// CHANGED: bridge takes chain_spec now (wires up EthBeaconConsensus).
let bridge = LiveRethEvmBridge::new(node.provider.clone(), chain_spec.clone());
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");
assert_eq!(block.parent_hash, BlockHash(genesis_hash_b256.0));
assert_eq!(block.number, 1);
// NEW: validate_payload runs EthBeaconConsensus against the live parent.
let status = bridge
.validate_payload(&block)
.await
.expect("validate_payload failed");
assert_eq!(status, PayloadStatus::Valid);
// NEW: unknown block hash → Invalid (we have no header to validate).
let unknown_block = ExecutedBlock {
hash: BlockHash([0xddu8; 32]),
parent_hash: BlockHash(genesis_hash_b256.0),
number: 1,
state_root: [0u8; 32],
};
let status = bridge
.validate_payload(&unknown_block)
.await
.expect("validate_payload failed");
assert_eq!(status, PayloadStatus::Invalid);
// (レッスン 12 からの negative case は変わらず。)
let fake_parent = BlockHash([0xeeu8; 32]);
let err = bridge.build_payload(fake_parent, attrs).await.unwrap_err();
assert!(matches!(err, BridgeError::Rejected(_)));
}
load-bearing は validate_payload(&block) → Valid — build と validate がルールに合意している証明(difficulty 非ゼロ / gas_limit drift / base_fee 間違いなら fail)。dev_chain_spec() は L11/L12 と同じ genesis JSON(Arc<ChainSpec>、.clone() は refcount increment のみ)。
答え合わせ
cd ~/code/openhl-reference && git checkout 0844d58
diff -u ~/code/my-openhl/crates/evm/src/live_node.rs ./crates/evm/src/live_node.rs
git checkout main
0844d58 に L12 からの ~141 行変更。新 struct フィールド・upgrade した build_payload・rewrite した validate_payload・新 assertion は厳密一致するべき。
合格基準
cargo test -p openhl-evm live_bridge_builds_on_real_genesis --release
→ 1 個 pass(Valid + Invalid assertion 込み、~2.4 秒)。Valid が落ちる主因: difficulty: U256::ZERO 忘れ / gas_limit = parent.gas_limit 忘れ / base_fee を next_block_base_fee 以外で計算 / timestamp が parent より大きくない / 拡張 trait EthChainSpec の use 忘れ(next_block_base_fee が解決しない)。
まとめ(3行)
- builder と validator が同じ
Arc<ChainSpec>を共有 —next_block_base_feeを両者が使い、hardfork 越しの drift を物理的に防ぐ。 validate_payloadはEthBeaconConsensus::validate_header_against_parentを走らせ、Err→Invalid(crash でなく protocol シグナル)。- trait bound が
+ HeaderProviderに拡張 — bound は bridge が要求する Reth surface の spec。