レッスン2 — openhl-types の共通 contract type
問い
CL と EL が共有する語彙(BlockHash、PayloadId など)は、どの crate に置くべきか? そして validate_payload の verdict は、なぜ bool ではダメなのか?
原理(最小モデル)
4 つの設計判断がこのレッスンの核だ。
- 共通語彙 crate(shared vocabulary crate)。
BlockHashをopenhl-consensusに置くと evm がそこに依存し、consensus も evm のメソッドを呼ぶ → A→B かつ B→A の循環依存。Rust は許さない。解決は中立な第三 crateopenhl-typesに両者が依存すること。Reth もalloy-primitivesで同じことをやる。 - Newtype パターン。
type BlockHash = [u8;32](alias)ではなくstruct BlockHash([u8;32])(wrap)。alias だと無関係な[u8;32]をBlockHashの場所に渡せてしまう。newtype は compiler に「これはただの32 byte ではなく block hash だ」と強制させる。 - 三状態の
PayloadStatus(Valid / Invalid / Syncing)。 EL の応答に対する consensus の action は 2 つでなく 3 つ(投票 / 否決 / 棄権)。boolに潰すと「棄権」が消える。 - custom
Display。 default のDebugはBlockHash([171, 18, ...])を出してログが読めない。0xab12...を出すDisplayを書く。ログは debugger の主戦場で、可読性は optional ではない。
具体例
newtype の効き目:
let h: BlockHash = [0u8; 32]; // ❌ compile error(明示的に BlockHash(...) で包む必要)
let h = BlockHash([0u8; 32]); // ✅
失敗例(誤解)
「PayloadStatus は bool { is_valid } で十分」は誤り。Syncing を Invalid 扱いすると、本来追いつけたはずの peer から 永続的に fork する。Invalid を Syncing 扱いすると bad proposal が timeout 経由で素通りし chain が腐る。3 状態は consensus の 3 action(投票 / Nil 投票 / 棄権)と 1:1。
ここまでで「どこに置くか・なぜ newtype・なぜ三状態」は着地した。ここから crates/types/src/lib.rs に 5 type を組み立てる。コードは完全形。
🛑 予測。 下のコードを読む前に:なぜ
PayloadStatusはboolでなく 3 variant の enum か? ヒント:EL が各回答を返したとき consensus が取れる action は 2 つでなく 3 つある。
ステップで組み立てる
Step 1: import(doc comment の下に)
//! Shared primitives and CL/EL contract types.
use std::fmt;
use serde::{Deserialize, Serialize};
std::fmt は BlockHash の Display 用、serde は全 type の derive 用(contract type は最終的に wire format で round-trip する)。
Step 2: BlockHash + Display
/// 32-byte block hash, Ethereum convention.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct BlockHash(pub [u8; 32]);
impl fmt::Display for BlockHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("0x")?;
for b in &self.0 {
write!(f, "{b:02x}")?;
}
Ok(())
}
}
要点: 32 bytes でも Copy(memcpy は安く、block hash は頻繁に渡す)。10 個の derive は contract type の標準セット(Ord はソート用、Hash は HashMap key、Serialize は wire)。{b:02x}(zero-pad 2桁)であって {b:x} ではない — 0x05 が "05" になる。
Step 3: PayloadId
/// Identifier returned by `build_payload`; used to retrieve the assembled block via `payload_ready`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PayloadId(pub u64);
同じ newtype。Display 不要(Debug で十分)、Ord 不要(build と ready の間で受け渡す不透明 token で順序付け不要)。素の u64 を使うと build_payload(..., 任意のu64) と書けてしまう footgun を newtype が防ぐ。
Step 4: PayloadAttrs
/// Inputs to a payload-build job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayloadAttrs {
pub timestamp: u64,
pub fee_recipient: [u8; 20],
pub prev_randao: [u8; 32],
}
複数フィールドなので real struct。Reth が payload を assemble する最小入力(timestamp / fee 送り先 / beacon randomness)。Engine API 仕様の他フィールド(withdrawals 等)は v0 では省略(single-validator で withdrawal flow なし)。60 bytes なので Copy は付けない。
Step 5: PayloadStatus(予測の答え)
/// Verdict from `validate_payload`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PayloadStatus {
Valid,
Invalid,
Syncing,
}
- Valid → block に投票
- Invalid → Nil 投票、proposer を faulty 扱い
- Syncing → 投票せず待つ/timeout に falling、sync 再試行
3 variant は互換でない。bool に潰すと Syncing の正しい挙動(棄権)が表現できない。
Step 6: ExecutedBlock
/// An executed block — the artifact a consensus round commits to. Minimal v0 shape; txs and receipts land per ステップ 2.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutedBlock {
pub hash: BlockHash,
pub parent_hash: BlockHash,
pub number: u64,
pub state_root: [u8; 32],
}
consensus round が閉じる最小形。意図的に 無い もの: transaction list / receipts / logs bloom(ステップ2 CLOB で定着)/ difficulty(post-merge default)。今最小にしておけば、ステップ2 を設計する前にステップ2 の design を encode してしまう事故を避けられる。
Step 7: dev-dep + 4 unit test
crates/types/Cargo.toml 末尾に先に dev-dep を入れてから test を書く(rust-analyzer の赤波線回避):
[dev-dependencies]
serde_json = { workspace = true }
crates/types/src/lib.rs 末尾に:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn block_hash_display_is_hex() {
let h = BlockHash([0xab; 32]);
let s = format!("{h}");
assert!(s.starts_with("0x"));
assert_eq!(s.len(), 2 + 64); // "0x" + 64 hex chars
assert!(s.ends_with("ab"));
}
#[test]
fn payload_status_equality() {
assert_eq!(PayloadStatus::Valid, PayloadStatus::Valid);
assert_ne!(PayloadStatus::Valid, PayloadStatus::Invalid);
assert_ne!(PayloadStatus::Syncing, PayloadStatus::Valid);
}
#[test]
fn executed_block_is_cloneable() {
let original = ExecutedBlock {
hash: BlockHash([1u8; 32]),
parent_hash: BlockHash([0u8; 32]),
number: 1,
state_root: [2u8; 32],
};
let cloned = original.clone();
assert_eq!(cloned.number, original.number);
assert_eq!(cloned.hash, original.hash);
}
#[test]
fn block_hash_serde_round_trips() {
let original = BlockHash([0x42; 32]);
let json = serde_json::to_string(&original).unwrap();
let round_tripped: BlockHash = serde_json::from_str(&json).unwrap();
assert_eq!(original, round_tripped);
}
}
答え合わせ
cd ~/code/openhl-reference && git checkout 13113db
diff -u ~/code/my-openhl/crates/types/src/lib.rs ./crates/types/src/lib.rs
git checkout main
空白・テスト名以外は実質同一になるはず。一致ポイント: type 定義(各フィールド・各 derive)、BlockHash::Display のロジック、PayloadStatus の variant 順序。
合格基準
cargo test -p openhl-types
→ 4 テスト pass(hex display / status equality / executed-block clone / serde round-trip)。よくあるミス: derive 書き忘れ / Display impl 欠落 / serde_json を dev-dep に入れ忘れ / {b:x} と書いて hex 長が合わない。
まとめ(3行)
- CL↔EL の共通語彙は中立な第三 crate
openhl-typesに置く(循環依存を避ける)。 - newtype(
BlockHash([u8;32]))で「ただの byte 列」と block hash を型レベルで区別する。 PayloadStatusは 3 状態 — bool に潰すとSyncingの「棄権」が消えて永続 fork を招く。