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

レッスン4 — InMemoryEvmBridge — trait の最初の impl

問い

レッスン3 で ConsensusBridge trait を型レベルで完成させた。では Reth(600 個の transitive dep)を立ち上げる前に、この契約を end-to-end で動かして consensus 側をテスト可能にするには、何があればいいか?

原理(最小モデル)

  • テストダブル先行。 Reth に触れず fake EVM を書く。trait を exercise するのに大依存を待たず、下流の consensus test(レッスン9/10)を 2.7s でなく 0.02s で回せる。
  • Mutex<State> で内部可変性。 レッスン3 の Send + Sync bound を満たすため、private State を 1 つの Mutex に包む。method ごとに 1 回 lock。このパターンはレッスン11+ の LiveRethEvmBridge にも構造的に伝播する。
  • pendingchain の分離。 投機的な build と確定した commit はライフサイクルが違う。build は pending に積み、commit だけが pending → chain に昇格させる唯一の権限を持つ。real Reth でも同じ split(pending blocks / canonical chain)。

具体例

ブロックのライフサイクル:

build_payload(parent, attrs)
  → parent number を chain から引く / PayloadId 発行 / hash 合成
  → pending.insert(id, block)         ◄ 投機的に格納、CL には PayloadId だけ返す
payload_ready(id)
  → pending.get(&id).cloned()         ◄ 未確定ブロックを CL に貸し出す(pending に残す)
commit(hash)                          ※ 2/3+ Quorum 達成後
  → pending から検索 → chain.insert(hash, block) / head = Some(hash)  ◄ canonical へ昇格

失敗例(誤解)

BlockHash に real な cryptographic hash を使うべき」は誤り。real hashing は EVM を走らせ post-state root を計算する必要があり、それを避けるのが test double の目的。合成 hash は (id, number)uniqueness だけ満たせばよい(cryptographic-commitment はレッスン11+ の実 Reth で)。


ここまでで「なぜ test double・Mutex・pending/chain 分離か」は着地した。ここから in_memory.rs を組み立てる。コードは完全形。

🛑 予測。 下を読む前に: test double の build_payloadfake する ものと 実際にできる ものは何か? ヒント: EVM は走らせられないが、PayloadId 割り当て・number インクリメント・hash 合成・pending 記憶はできる。

ステップで組み立てる

Step 1: crates/evm/Cargo.toml

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

[dev-dependencies]
tokio = { workspace = true }

openhl-consensus(trait/error)、openhl-types(contract 型)、async-trait(impl の #[async_trait])、dev に tokio#[tokio::test])。

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

//! In-memory `ConsensusBridge` — a test double for the EL side.
//!
//! Useful for unit-testing the consensus crate without spinning up Reth. The
//! real Reth-backed implementation lives in `engine.rs` (lands in レッスン 5).

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::fmt::Write as _;
use std::sync::Mutex;

#[derive(Debug, Default)]
pub struct InMemoryEvmBridge {
    state: Mutex<State>,
}

#[derive(Debug, Default)]
struct State {
    next_payload_id: u64,
    pending: HashMap<u64, ExecutedBlock>,
    chain: HashMap<[u8; 32], ExecutedBlock>,
    head: Option<BlockHash>,
}

impl InMemoryEvmBridge {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }
}

State の 4 フィールド: next_payload_id(単調カウンタ)/ pending(build 済み・未 commit、PayloadId が key)/ chain(commit 済み、生の [u8;32] が key で .0 を省ける)/ head(最新 commit hash)。

Step 3: build_payload

#[async_trait]
impl ConsensusBridge for InMemoryEvmBridge {
    async fn build_payload(
        &self,
        parent: BlockHash,
        _attrs: PayloadAttrs,
    ) -> Result<PayloadId, BridgeError> {
        let mut s = self.state.lock().expect("state mutex poisoned");
        let id = s.next_payload_id;
        s.next_payload_id += 1;

        let parent_number = s.chain.get(&parent.0).map_or(0, |b| b.number);
        let number = parent_number + 1;

        let mut hash_bytes = [0u8; 32];
        hash_bytes[..8].copy_from_slice(&id.to_le_bytes());
        hash_bytes[8..16].copy_from_slice(&number.to_le_bytes());

        let block = ExecutedBlock {
            hash: BlockHash(hash_bytes),
            parent_hash: parent,
            number,
            state_root: [0u8; 32],
        };
        s.pending.insert(id, block);
        Ok(PayloadId(id))
    }

要点: .expect("state mutex poisoned") は前の holder が panic して poisoned な state を残したケースをカバー(poisoned な state machine から続けるのは unsafe なので自分も panic)。hash は (id, number) から合成 — test double では uniqueness だけ満たせばよい(予測の答え: fake = post-state root、できる = id/number/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;
        s.pending
            .get(&n)
            .cloned()
            .ok_or_else(|| BridgeError::Rejected(format!("unknown payload id {n}")))
    }

    async fn validate_payload(
        &self,
        _block: &ExecutedBlock,
    ) -> Result<PayloadStatus, BridgeError> {
        Ok(PayloadStatus::Valid)
    }

    async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError> {
        let mut s = self.state.lock().expect("state mutex poisoned");
        let block = s
            .pending
            .values()
            .find(|b| b.hash == block_hash)
            .cloned()
            .ok_or_else(|| {
                let hex = hex_short(&block_hash.0);
                BridgeError::Rejected(format!("commit for unknown hash {hex}"))
            })?;
        s.chain.insert(block_hash.0, block);
        s.head = Some(block_hash);
        Ok(())
    }
}

payload_ready は唯一 read-only(mut 不要)、clone して貸し出す(commit 前に再問い合わせがあるかも)。validate_payload は test double なので常に Valid(real validation はレッスン12)。_block の leading underscore で unused 警告を抑制。commitpending.values() を scan(pending は PayloadId が key なので hash 検索には scan が要る — test では O(n) で OK)。

Step 5: hex_short ヘルパー(impl ブロックの外、ファイル末尾の非公開関数)

fn hex_short(bytes: &[u8; 32]) -> String {
    let mut s = String::with_capacity(18);
    s.push_str("0x");
    for b in &bytes[..8] {
        write!(&mut s, "{b:02x}").expect("write to String never fails");
    }
    s
}

impl ブロックの外に置く(&self を取らない純粋な変換ユーティリティ。impl 内に書くと trait 側にもこの method が必要だと誤解させる)。write! には Step 2 の use std::fmt::Write as _; が要る(as _ で method だけ import し名前で namespace を汚さない)。

Step 6: crate に組み込む

crates/evm/src/lib.rs:

//! EVM execution layer — Reth integration.

pub mod in_memory;

pub use in_memory::InMemoryEvmBridge;

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

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

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

    #[tokio::test]
    async fn build_then_ready_returns_same_block() {
        let bridge = InMemoryEvmBridge::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);
    }

    #[tokio::test]
    async fn validate_returns_valid() {
        let bridge = InMemoryEvmBridge::new();
        let block = ExecutedBlock {
            hash: BlockHash([2u8; 32]),
            parent_hash: BlockHash([1u8; 32]),
            number: 1,
            state_root: [0u8; 32],
        };
        let status = bridge.validate_payload(&block).await.unwrap();
        assert_eq!(status, PayloadStatus::Valid);
    }

    #[tokio::test]
    async fn commit_advances_head_and_records_block() {
        let bridge = InMemoryEvmBridge::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(block.hash));
        assert!(s.chain.contains_key(&block.hash.0));
    }

    #[tokio::test]
    async fn build_on_committed_parent_increments_number() {
        let bridge = InMemoryEvmBridge::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 = InMemoryEvmBridge::new();
        let err = bridge.commit(BlockHash([9u8; 32])).await.unwrap_err();
        assert!(matches!(err, BridgeError::Rejected(_)));
    }
}

#[tokio::test]#[test] の async 版(tokio runtime をセットアップして async 本体を await)。

答え合わせ

cd ~/code/openhl-reference && git checkout 3b43586
diff -u ~/code/my-openhl/crates/evm/src/in_memory.rs ./crates/evm/src/in_memory.rs
git checkout main

テスト順・doc・debug message は違って OK。struct の形、Mutex<State> パターン、4 method のロジックはほぼ一致。

合格基準

cargo test -p openhl-evm

5 テスト pass。よくあるミス: + 1 忘れで number=0 / tokio を dev-dep に入れ忘れ / use std::fmt::Write as _ が関数内にあって macro から見えない。

まとめ(3行)

  • Reth に触れず fake EVM(test double)で trait を exercise し、consensus test を 0.02s で回す。
  • Mutex<State>Send + Sync を満たす(レッスン11+ の実 bridge にも伝播)。
  • pending(投機)と chain(確定)を分離 — commit だけが pending → chain に昇格させる。