FABRKNT
Step 1. Consensus — cargo init から single-validator devnet 構築
EL test double
レッスン 6 / 16·CONTENT40 分70 XP
コース
Step 1. Consensus — cargo init から single-validator devnet 構築
レッスンの役割
CONTENT
順序
6 / 16

レッスン5 — real alloy 型を使う RethEvmBridge

問い

レッスン4 と同じ ConsensusBridge を、合成型ではなく 本物の alloy / Reth 型B256, Header)で満たすには? そして合成 hash と real hash の違いは、どんな挙動を testable にするか?

原理(最小モデル)

  • 内部は alloy-native、trait 型は contract の serialization。 State は (B256, Header) を保存し、trait は ExecutedBlock を返す。変換は trait 境界でだけ。alloy が進化しても変換ヘルパーを直すだけで contract は壊れない。
  • Header::hash_slow() で real RLP hashing。 header 全体を RLP encode + Keccak-256。"slow" は「cache なし、毎回再計算」の命名 convention。Ethereum node が計算するのと同じ hash になる。
  • (B256, Header) タプルで保持。 hash は ちょうどこの header の hash。別フィールドに分けると header 変更で cache hash が desync する。タプルが両者を不可分にする。
  • 1 trait・2 impl。 InMemoryEvmBridgeRethEvmBridge は trait surface を共有し fidelity だけ違う。レッスン11+ で 3 つ目(LiveRethEvmBridge)に広がる。

具体例

型境界のレイアウト:

[ 外側: CL 空間 ]  openhl-types: BlockHash / PayloadId / ExecutedBlock
        ▲ │   trait boundary でだけ変換(to_b256 / from_b256 / to_executed_block)
        │ ▼
[ 内側: EL 空間 ]  alloy: B256 / u64 / Header
   ※ State は (B256, Header) タプル — hash と Header を常に同期させる

contract 型は 4 method の signature/戻り値にしか現れず、impl 内部は全て alloy 型。

失敗例(誤解)

B256BlockHash[u8;32] を wrap している。transmute で変換できる」は誤り。byte layout は同一でも型は別物 — それが point。変換関数が境界の場所を document する。将来 BlockHash が checksum 等の metadata を持てば transmute はバグになるが、to_b256 は更新すべき場所として残る。


ここまでで「内部 alloy / 境界変換・real hashing・タプル保持」は着地した。ここから engine.rs を組み立てる。コードは完全形。

🛑 予測。 レッスン4 は hash を (id, number) から合成した。レッスン5 は header.hash_slow() を呼ぶ。この違いで testable になる挙動は何か? ヒント: header の 1 フィールド(timestamp)を変えると hash はどうなるか。

ステップで組み立てる

Step 1: crates/evm/Cargo.toml に alloy 依存を追加

[dependencies]
openhl-consensus = { workspace = true }
openhl-types     = { workspace = true }
async-trait      = { workspace = true }
alloy-primitives = { workspace = true }
alloy-consensus  = { workspace = true }

alloy-primitivesB256(32-byte hash)/ Address(20-byte)、alloy-consensusHeader(Ethereum block header 全フィールド)。

Step 2: crates/evm/src/engine.rs — doc + imports + struct

//! Reth-backed `ConsensusBridge` — uses alloy / Reth types throughout.
//!
//! At v0 this maintains state in-process for the parts that would normally
//! require a running Reth node (`PayloadBuilder` service, `BlockchainProvider`).
//! The live-node bootstrap lands in later lessons (レッスン 10〜13); the type
//! conversions and state-machine shape here are the contract that bootstrap
//! will satisfy.

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 std::collections::HashMap;
use std::sync::Mutex;

#[derive(Debug, Default)]
pub struct RethEvmBridge {
    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 RethEvmBridge {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }
}

レッスン4 と同じ shape だが State 内の型が違う: pending(B256, Header)chain/head の key が B256(B256, Header) で保持するのは hash_slow() が expensive だから — insert 時に 1 度 hash を計算して cache する。

Step 3: build_payload — 初めての real hashing

#[async_trait]
impl ConsensusBridge for RethEvmBridge {
    async fn build_payload(
        &self,
        parent: BlockHash,
        attrs: PayloadAttrs,
    ) -> Result<PayloadId, BridgeError> {
        let parent_hash = to_b256(parent);
        let mut s = self.state.lock().expect("state mutex poisoned");

        let parent_number = s.chain.get(&parent_hash).map_or(0, |h| h.number);
        let id = s.next_payload_id;
        s.next_payload_id += 1;

        let header = Header {
            parent_hash,
            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))
    }

4 フィールドだけ set し ..Default::default() で残り(state_root, gas_limit 等)を埋める。header.hash_slow() が本物の hash — header の 1 byte でも変われば hash が変わる(予測の答え: timestamp を変えると hash が変わる、合成 hash にはなかった性質)。

Step 4: payload_ready / validate_payload / commit

    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(to_executed_block(hash, &header))
    }

    async fn validate_payload(
        &self,
        _block: &ExecutedBlock,
    ) -> Result<PayloadStatus, BridgeError> {
        // Real validation requires a live Reth provider + EVM (lessons レッスン 11+).
        // For now, defer to the CL's voting layer for actual block validity
        // and accept structurally.
        Ok(PayloadStatus::Valid)
    }

    async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError> {
        let hash = to_b256(block_hash);
        let mut s = self.state.lock().expect("state mutex poisoned");
        let header = s
            .pending
            .values()
            .find(|(h, _)| *h == hash)
            .map(|(_, header)| header.clone())
            .ok_or_else(|| BridgeError::Rejected(format!("commit for unknown hash {hash}")))?;
        s.chain.insert(hash, header);
        s.head = Some(hash);
        Ok(())
    }
}

find(|(h, _)| *h == hash) はタプルを destructure して 1 番目を比較。*hB256Copy なので memcpy が走るだけで所有権 move しない。

Step 5: 変換ヘルパー 3 つ(impl ブロックの後)

fn to_b256(h: BlockHash) -> B256 {
    B256::from(h.0)
}

fn from_b256(b: B256) -> BlockHash {
    BlockHash(b.0)
}

fn to_executed_block(hash: B256, header: &Header) -> ExecutedBlock {
    ExecutedBlock {
        hash: from_b256(hash),
        parent_hash: from_b256(header.parent_hash),
        number: header.number,
        state_root: header.state_root.0,
    }
}

3 つに分けるのは各々が 1 つのことだけをするから(to_b256/from_b256 は pure な型橋渡し、to_executed_block がフィールド mapping を知る)。

Step 6: crate に組み込む

crates/evm/src/lib.rs:

//! EVM execution layer — Reth integration.

pub mod engine;
pub mod in_memory;

pub use engine::RethEvmBridge;
pub use in_memory::InMemoryEvmBridge;

Step 7: 4 unit test(ファイル末尾)

#[cfg(test)]
mod tests {
    use super::*;

    fn attrs() -> PayloadAttrs {
        PayloadAttrs {
            timestamp: 42,
            fee_recipient: [0xaa; 20],
            prev_randao: [0xbb; 32],
        }
    }

    #[tokio::test]
    async fn build_then_ready_returns_alloy_hashed_block() {
        let bridge = RethEvmBridge::new();
        let parent = BlockHash([1u8; 32]);
        let id = bridge.build_payload(parent, attrs()).await.unwrap();
        let block = bridge.payload_ready(id).await.unwrap();
        assert_eq!(block.parent_hash, parent);
        assert_eq!(block.number, 1);
        // Hash is computed by alloy_consensus::Header::hash_slow, not synthesized:
        // it changes if any header field changes.
        let mut alt_attrs = attrs();
        alt_attrs.timestamp += 1;
        let id2 = bridge.build_payload(parent, alt_attrs).await.unwrap();
        let block2 = bridge.payload_ready(id2).await.unwrap();
        assert_ne!(block.hash, block2.hash);
    }

    #[tokio::test]
    async fn commit_advances_head() {
        let bridge = RethEvmBridge::new();
        let parent = BlockHash([1u8; 32]);
        let id = bridge.build_payload(parent, attrs()).await.unwrap();
        let block = bridge.payload_ready(id).await.unwrap();
        bridge.commit(block.hash).await.unwrap();
        let s = bridge.state.lock().unwrap();
        assert_eq!(s.head, Some(to_b256(block.hash)));
    }

    #[tokio::test]
    async fn build_on_committed_parent_increments_number() {
        let bridge = RethEvmBridge::new();
        let genesis = BlockHash([1u8; 32]);
        let id1 = bridge.build_payload(genesis, attrs()).await.unwrap();
        let block1 = bridge.payload_ready(id1).await.unwrap();
        bridge.commit(block1.hash).await.unwrap();

        let id2 = bridge.build_payload(block1.hash, attrs()).await.unwrap();
        let block2 = bridge.payload_ready(id2).await.unwrap();
        assert_eq!(block2.number, 2);
        assert_eq!(block2.parent_hash, block1.hash);
    }

    #[tokio::test]
    async fn commit_unknown_hash_errors() {
        let bridge = RethEvmBridge::new();
        let err = bridge.commit(BlockHash([9u8; 32])).await.unwrap_err();
        assert!(matches!(err, BridgeError::Rejected(_)));
    }
}

鍵は最初のテスト: 同じ parent でも timestamp を変えると hash が変わる — レッスン4 の合成 hash には書けなかったテスト(same parent + same number → same synthesized hash で timestamp を無視した)。

答え合わせ

cd ~/code/openhl-reference && git checkout c938321
diff -u ~/code/my-openhl/crates/evm/src/engine.rs ./crates/evm/src/engine.rs
git checkout main

doc/error message の variation は OK。struct 型・helper signature・4 method のロジックはほぼ一致。reference の c938321 の Cargo.toml には後のレッスン用に reth-ethereum-primitives も列挙されている(engine.rs では未使用、省略して OK)。

合格基準

cargo test -p openhl-evm

9 テスト pass(レッスン4 の 5 + レッスン5 の 4)。両 impl が同じ trait を満たし、レッスン8/9 の同じ consumer コードがどちらにも動く。よくあるミス: hash_slow() の戻りを BlockHash に直代入(B256 なので from_b256)/ ..Default::default() 忘れ / fee_recipient[u8;20])に B256::from(正しくは Address::from)。

まとめ(3行)

  • 内部は alloy-native(B256/Header)、trait 型は境界での serialization — alloy 進化に強い。
  • header.hash_slow() で real RLP hashing(header 1 フィールド変更で hash が変わるのを test で証明)。
  • 1 trait・2 impl の多相性が、レッスン11+ の 3 つ目 LiveRethEvmBridge へそのまま広がる。