FABRKNT
Step 1. Consensus — cargo init から single-validator devnet 構築
Contract types
レッスン 3 / 16·CONTENT30 分60 XP
コース
Step 1. Consensus — cargo init から single-validator devnet 構築
レッスンの役割
CONTENT
順序
3 / 16

レッスン2 — openhl-types の共通 contract type

問い

CL と EL が共有する語彙(BlockHashPayloadId など)は、どの crate に置くべきか? そして validate_payload の verdict は、なぜ bool ではダメなのか?

原理(最小モデル)

4 つの設計判断がこのレッスンの核だ。

  • 共通語彙 crate(shared vocabulary crate)。 BlockHashopenhl-consensus に置くと evm がそこに依存し、consensus も evm のメソッドを呼ぶ → A→B かつ B→A の循環依存。Rust は許さない。解決は中立な第三 crate openhl-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 の DebugBlockHash([171, 18, ...]) を出してログが読めない。0xab12... を出す Display を書く。ログは debugger の主戦場で、可読性は optional ではない。

具体例

newtype の効き目:

let h: BlockHash = [0u8; 32];   // ❌ compile error(明示的に BlockHash(...) で包む必要)
let h = BlockHash([0u8; 32]);   // ✅

失敗例(誤解)

PayloadStatusbool { is_valid } で十分」は誤り。SyncingInvalid 扱いすると、本来追いつけたはずの peer から 永続的に fork する。InvalidSyncing 扱いすると bad proposal が timeout 経由で素通りし chain が腐る。3 状態は consensus の 3 action(投票 / Nil 投票 / 棄権)と 1:1。


ここまでで「どこに置くか・なぜ newtype・なぜ三状態」は着地した。ここから crates/types/src/lib.rs に 5 type を組み立てる。コードは完全形。

🛑 予測。 下のコードを読む前に:なぜ PayloadStatusbool でなく 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::fmtBlockHashDisplay 用、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 を招く。