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

レッスン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 の ErrInternal にマップ」も誤り — 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_numberu64 だけ返すのか? ヒント: 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-consensusHeaderValidator trait)/ reth-ethereum-consensus(具象 EthBeaconConsensus)/ reth-primitives-traitsSealedHeader)。後者だけ crates.io(git でない) — Reth の中で外部エコシステムに stabilize された部分で、crates.io publish パッケージは git-rev 依存を含められない(Cargo の制約)。

Step 2: crates/evm/Cargo.tomlreth-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 のまま不変。EthChainSpecChainSpecnext_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)。ErrInvalid にマップするのは 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 個 passValid + Invalid assertion 込み、~2.4 秒)。Valid が落ちる主因: difficulty: U256::ZERO 忘れ / gas_limit = parent.gas_limit 忘れ / base_fee を next_block_base_fee 以外で計算 / timestamp が parent より大きくない / 拡張 trait EthChainSpecuse 忘れ(next_block_base_fee が解決しない)。

まとめ(3行)

  • builder と validator が同じ Arc<ChainSpec> を共有 — next_block_base_fee を両者が使い、hardfork 越しの drift を物理的に防ぐ。
  • validate_payloadEthBeaconConsensus::validate_header_against_parent を走らせ、ErrInvalid(crash でなく protocol シグナル)。
  • trait bound が + HeaderProvider に拡張 — bound は bridge が要求する Reth surface の spec。