レッスン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 + Syncbound を満たすため、privateStateを 1 つのMutexに包む。method ごとに 1 回 lock。このパターンはレッスン11+ のLiveRethEvmBridgeにも構造的に伝播する。pendingとchainの分離。 投機的な 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_payloadが fake する ものと 実際にできる ものは何か? ヒント: 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 警告を抑制。commit は pending.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 に昇格させる。