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

レッスン6 — OpenHlContext と Malachite の 10 sub-type

問い

レッスン3 の ConsensusBridge自分が所有 する trait だった。Malachite の Context は逆で、Malachite が所有 する trait に自分の型をはめる。10 個の型をどう束ね、全 validator が同じ proposer を選ぶことをどう保証するか?

原理(最小モデル)

  • 両側の trait contract。 自分が定義した契約に impl を書く(レッスン3)のと、外部ライブラリの契約に自分の型をはめる(本レッスン)のは、設計の力学が逆向き。
  • Context associated-type パターン。 state を持たない空 struct OpenHlContext; が 10 個の sub-type(Address/Height/Value/Validator/…)に名前を付ける。この type-family idiom が Malachite を chain-generic にしている。
  • 型システムが不変条件を強制。 OpenHlValidatorSet::new() が構築時に sort する → 「未 sort な set」が表現不能になる。下流 method はすべて sort 済みを前提にしてよい。
  • 決定的 proposer 選択。 sort 済み set に (height + round) % count。全 validator が同一に検証できる最も単純な決定的アルゴリズム。

具体例

3 validator(A:300 / B:200 / C:100 stake)。sort 済みセット(power 降順)に (height+round)%count:

Index 0→A(300)  Index 1→B(200)  Index 2→C(100)
H1 R0 → (1+0)%3 = 1 → B
H1 R1 → (1+1)%3 = 2 → C   (round で rotation)
H1 R2 → (1+2)%3 = 0 → A
H2 R0 → (2+0)%3 = 2 → C   (height で rotation)

失敗例(誤解)

「validator ごとに好きな順で sort してよい」は誤り。A が [A,B,C]、B が [B,A,C] で sort すると、同じ (H1,R0) で A は「Index1=B」、B は「Index1=A」を proposer と認識 → 最初の round で chain が forksort 順 = proposer-election protocol の本体。また「Value を直接 BlockHash に」も誤り(Value trait は独自 bound + Id associated type を要求するのでラップする)。


ここまでで「Context は外部所有の trait・sort 順が proposer protocol」は着地した。ここから 8 ファイル(types/ 7 + context.rs)を組み立てる。コードは完全形。

🛑 予測。 なぜ OpenHlValidatorSet の sort 順 と select_proposer のアルゴリズムは、全 validator で一致しなければならないか? ヒント: 同じ (height, round) で validator が違う proposer を選んだら chain はどうなる?

ステップで組み立てる

最終的なファイル構造:

crates/consensus/src/
├── lib.rs        (Step 7)
├── bridge.rs     (レッスン3、変更なし)
├── context.rs    (Step 6: OpenHlContext + 4 factory + テスト) ★中央
└── types/        (Step 2)
    ├── mod.rs / address.rs / height.rs / value.rs       (Step 2-3)
    ├── validator.rs                                     (Step 4) ★最重要
    └── proposal.rs / proposal_part.rs / vote.rs         (Step 5)

Step 1: crates/consensus/Cargo.toml

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

informalsystems-malachitebft-core-types      = { workspace = true }
informalsystems-malachitebft-signing-ed25519 = { workspace = true, features = ["rand"] }

[dev-dependencies]
rand = "0.8"

-core-typesContext trait + 10 sub-trait を定義。-signing-ed25519rand feature で PrivateKey::generate(OsRng) がテストで使える。

Step 2: types/mod.rs(module index)

//! Concrete implementations of Malachite's `Context` sub-traits.

pub mod address;
pub mod height;
pub mod proposal;
pub mod proposal_part;
pub mod validator;
pub mod value;
pub mod vote;

pub use address::OpenHlAddress;
pub use height::OpenHlHeight;
pub use proposal::OpenHlProposal;
pub use proposal_part::OpenHlProposalPart;
pub use validator::{OpenHlValidator, OpenHlValidatorSet};
pub use value::OpenHlValue;
pub use vote::OpenHlVote;

型 1 つにつき 1 ファイルにするのは、各型の設計判断が distinct で、walk・review を局所化できるから。

Step 3: シンプル 3 型 — address.rs / height.rs / value.rs

// address.rs
use core::fmt;
use informalsystems_malachitebft_core_types::Address;

/// A 20-byte validator address, Ethereum convention.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OpenHlAddress(pub [u8; 20]);

impl fmt::Display for OpenHlAddress {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("0x")?;
        for b in &self.0 {
            write!(f, "{b:02x}")?;
        }
        Ok(())
    }
}

impl Address for OpenHlAddress {}

Address trait はメソッドを持たず、derive 一式(Clone+Copy+Debug+Display+PartialEq+Eq+PartialOrd+Ord+Hash)を 要求する だけ。

// height.rs
use core::fmt;
use informalsystems_malachitebft_core_types::Height;

/// Block height — a monotonic u64 counter.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OpenHlHeight(pub u64);

impl fmt::Display for OpenHlHeight {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl Height for OpenHlHeight {
    const ZERO: Self = OpenHlHeight(0);
    const INITIAL: Self = OpenHlHeight(1);

    fn increment_by(&self, n: u64) -> Self {
        OpenHlHeight(self.0.saturating_add(n))
    }

    fn decrement_by(&self, n: u64) -> Option<Self> {
        self.0.checked_sub(n).map(OpenHlHeight)
    }

    fn as_u64(&self) -> u64 {
        self.0
    }
}

INITIAL = 1(genesis は block 0 だが consensus が produce するものではない)。increment_bysaturating_add(overflow panic 回避)、decrement_bychecked_sub で 0 未満を None

// value.rs
use informalsystems_malachitebft_core_types::Value;
use openhl_types::BlockHash;

/// The value consensus agrees on: an EVM block, identified by its block hash.
///
/// For v0 we store only the hash since the EVM bridge is the source of truth
/// for block contents. ステップ 2 will extend this to carry the full block once
/// the CLOB starts producing fills that need to be ordered alongside EVM txs.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OpenHlValue(pub BlockHash);

impl Value for OpenHlValue {
    type Id = BlockHash;

    fn id(&self) -> Self::Id {
        self.0
    }
}

Value::Id は vote に乗るもの — consensus は full value でなく identifier(hash)に投票する。

Step 4: validator.rs — canonical sort(最重要)

use informalsystems_malachitebft_core_types::{Validator, ValidatorSet, VotingPower};
use informalsystems_malachitebft_signing_ed25519::PublicKey;

use crate::context::OpenHlContext;
use crate::types::OpenHlAddress;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpenHlValidator {
    pub address: OpenHlAddress,
    pub public_key: PublicKey,
    pub voting_power: VotingPower,
}

impl OpenHlValidator {
    #[must_use]
    pub const fn new(address: OpenHlAddress, public_key: PublicKey, voting_power: VotingPower) -> Self {
        Self { address, public_key, voting_power }
    }
}

impl Validator<OpenHlContext> for OpenHlValidator {
    fn address(&self) -> &OpenHlAddress {
        &self.address
    }

    fn public_key(&self) -> &PublicKey {
        &self.public_key
    }

    fn voting_power(&self) -> VotingPower {
        self.voting_power
    }
}

/// A validator set, kept sorted by (`voting_power` desc, address asc).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpenHlValidatorSet(Vec<OpenHlValidator>);

impl OpenHlValidatorSet {
    /// Construct a validator set and enforce the canonical sort order.
    #[must_use]
    pub fn new(mut validators: Vec<OpenHlValidator>) -> Self {
        validators.sort_by(|a, b| {
            b.voting_power
                .cmp(&a.voting_power)
                .then_with(|| a.address.cmp(&b.address))
        });
        Self(validators)
    }

    #[must_use]
    pub fn validators(&self) -> &[OpenHlValidator] {
        &self.0
    }
}

impl ValidatorSet<OpenHlContext> for OpenHlValidatorSet {
    fn count(&self) -> usize {
        self.0.len()
    }

    fn total_voting_power(&self) -> VotingPower {
        self.0.iter().map(|v| v.voting_power).sum()
    }

    fn get_by_address(&self, address: &OpenHlAddress) -> Option<&OpenHlValidator> {
        self.0.iter().find(|v| &v.address == address)
    }

    fn get_by_index(&self, index: usize) -> Option<&OpenHlValidator> {
        self.0.get(index)
    }
}

sort comparator が canonical CometBFT 順(power 降順 → tiebreak address 昇順)。then_with で total ordering になるので入力順に依存せず一意な並びに収束する。全 validator が同じ sort を適用しないと select_proposer が割れて fork する(予測の答え)。power 降順なのは高 stake ほど低 index に来て modulo で多く選ばれるべきだから。

Step 5: メッセージ型 — proposal.rs / proposal_part.rs / vote.rs

// proposal.rs
use informalsystems_malachitebft_core_types::{Proposal, Round};

use crate::context::OpenHlContext;
use crate::types::{OpenHlAddress, OpenHlHeight, OpenHlValue};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpenHlProposal {
    pub height: OpenHlHeight,
    pub round: Round,
    pub value: OpenHlValue,
    pub pol_round: Round,
    pub address: OpenHlAddress,
}

impl Proposal<OpenHlContext> for OpenHlProposal {
    fn height(&self) -> OpenHlHeight {
        self.height
    }

    fn round(&self) -> Round {
        self.round
    }

    fn value(&self) -> &OpenHlValue {
        &self.value
    }

    fn take_value(self) -> OpenHlValue {
        self.value
    }

    fn pol_round(&self) -> Round {
        self.pol_round
    }

    fn validator_address(&self) -> &OpenHlAddress {
        &self.address
    }
}

pol_round(Proof of Lock Round)は Tendermint の概念 — lock した round。初回 proposal では Round::Nil

// proposal_part.rs
use informalsystems_malachitebft_core_types::ProposalPart;

use crate::context::OpenHlContext;

/// Unit proposal part — `OpenHL` runs in `ValuePayload::ProposalOnly` mode, so
/// the entire value ships in the `Proposal` message and parts are unused.
/// The type is required by the `Context` trait surface anyway.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OpenHlProposalPart;

impl ProposalPart<OpenHlContext> for OpenHlProposalPart {
    fn is_first(&self) -> bool {
        true
    }

    fn is_last(&self) -> bool {
        true
    }
}

unit struct。OpenHlValue がただの BlockHash(32 byte)なので streaming 不要 → ValuePayload::ProposalOnly で value 全体が Proposal に乗る。だが ContextProposalPart 型を要求するので実体化しない unit で満たす。

// vote.rs
use informalsystems_malachitebft_core_types::{
    NilOrVal, Round, SignedExtension, VoteType, Vote as VoteTrait,
};
use openhl_types::BlockHash;

use crate::context::OpenHlContext;
use crate::types::{OpenHlAddress, OpenHlHeight};

#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct OpenHlVote {
    pub height: OpenHlHeight,
    pub round: Round,
    pub value_id: NilOrVal<BlockHash>,
    pub vote_type: VoteType,
    pub address: OpenHlAddress,
}

impl VoteTrait<OpenHlContext> for OpenHlVote {
    fn height(&self) -> OpenHlHeight {
        self.height
    }

    fn round(&self) -> Round {
        self.round
    }

    fn value(&self) -> &NilOrVal<BlockHash> {
        &self.value_id
    }

    fn take_value(self) -> NilOrVal<BlockHash> {
        self.value_id
    }

    fn vote_type(&self) -> VoteType {
        self.vote_type
    }

    fn validator_address(&self) -> &OpenHlAddress {
        &self.address
    }

    fn extension(&self) -> Option<&SignedExtension<OpenHlContext>> {
        None
    }

    fn take_extension(&mut self) -> Option<SignedExtension<OpenHlContext>> {
        None
    }

    fn extend(self, _extension: SignedExtension<OpenHlContext>) -> Self {
        self
    }
}

OpenHlVote が prevote/precommit 両方を表し vote_type で区別。extension 3 メソッドは None/no-op(v0 では vote extension 未使用、Context で Extension = ())。NilOrValOption でない)は BFT 固有で Nil =「この round の任意の value に反対」。

Step 6: context.rs — 結束

//! `OpenHlContext` — the central abstraction Malachite uses to know about our chain.
//!
//! Once this trait is implemented, the entire `malachitebft-core-consensus` and
//! `malachitebft-engine` machinery can drive consensus over our types.

use informalsystems_malachitebft_core_types::{
    Context, NilOrVal, Round, ValidatorSet as _, ValueId, VoteType,
};
use informalsystems_malachitebft_signing_ed25519::Ed25519;

use crate::types::{
    OpenHlAddress, OpenHlHeight, OpenHlProposal, OpenHlProposalPart, OpenHlValidator,
    OpenHlValidatorSet, OpenHlValue, OpenHlVote,
};

#[derive(Clone, Debug, Default)]
pub struct OpenHlContext;

impl Context for OpenHlContext {
    type Address = OpenHlAddress;
    type Height = OpenHlHeight;
    type ProposalPart = OpenHlProposalPart;
    type Proposal = OpenHlProposal;
    type Validator = OpenHlValidator;
    type ValidatorSet = OpenHlValidatorSet;
    type Value = OpenHlValue;
    type Vote = OpenHlVote;
    type Extension = ();
    type SigningScheme = Ed25519;

    fn select_proposer<'a>(
        &self,
        validator_set: &'a Self::ValidatorSet,
        height: Self::Height,
        round: Round,
    ) -> &'a Self::Validator {
        let count = validator_set.count();
        assert!(count > 0, "validator set is empty");
        let round_u64 = u64::try_from(round.as_i64().max(0)).unwrap_or(0);
        let index_u64 = height.0.wrapping_add(round_u64);
        let index = usize::try_from(index_u64).unwrap_or(usize::MAX) % count;
        validator_set
            .get_by_index(index)
            .expect("index < count by construction")
    }

    fn new_proposal(
        &self,
        height: Self::Height,
        round: Round,
        value: Self::Value,
        pol_round: Round,
        address: Self::Address,
    ) -> Self::Proposal {
        OpenHlProposal { height, round, value, pol_round, address }
    }

    fn new_prevote(
        &self,
        height: Self::Height,
        round: Round,
        value_id: NilOrVal<ValueId<Self>>,
        address: Self::Address,
    ) -> Self::Vote {
        OpenHlVote {
            height,
            round,
            value_id,
            vote_type: VoteType::Prevote,
            address,
        }
    }

    fn new_precommit(
        &self,
        height: Self::Height,
        round: Round,
        value_id: NilOrVal<ValueId<Self>>,
        address: Self::Address,
    ) -> Self::Vote {
        OpenHlVote {
            height,
            round,
            value_id,
            vote_type: VoteType::Precommit,
            address,
        }
    }
}

OpenHlContext は unit struct(state なし、型を関連付けるマーカー)。Extension = ()(vote extension なし)、SigningScheme = Ed25519(Malachite 同梱)。select_proposer = (height+round)%count が決定的 — sort 済み set + 同じ (height,round) → 全 validator が同じ proposer。wrapping_add で overflow 回避、% countindex < count を保証。

Step 7: lib.rs

//! Consensus layer — Malachite BFT.

pub mod bridge;
pub mod context;
pub mod types;

pub use context::OpenHlContext;

Step 8: 5 unit test(context.rs 末尾)

#[cfg(test)]
mod tests {
    use super::*;
    use informalsystems_malachitebft_core_types::{
        Height as HeightTrait, Proposal as ProposalTrait, Validator, ValidatorSet,
        Vote as VoteTrait,
    };
    use informalsystems_malachitebft_signing_ed25519::PrivateKey;
    use openhl_types::BlockHash;
    use rand::rngs::OsRng;

    fn validator(addr_byte: u8, power: u64) -> OpenHlValidator {
        let private = PrivateKey::generate(OsRng);
        let public = private.public_key();
        OpenHlValidator::new(OpenHlAddress([addr_byte; 20]), public, power)
    }

    #[test]
    fn validator_set_is_sorted_by_power_then_address() {
        let set = OpenHlValidatorSet::new(vec![
            validator(0x01, 100),
            validator(0x02, 300),
            validator(0x03, 200),
        ]);
        let powers: Vec<u64> = set
            .validators()
            .iter()
            .map(Validator::voting_power)
            .collect();
        assert_eq!(powers, vec![300, 200, 100]);
        assert_eq!(set.total_voting_power(), 600);
        assert_eq!(set.count(), 3);
    }

    #[test]
    fn select_proposer_round_robins_deterministically() {
        let ctx = OpenHlContext;
        let set = OpenHlValidatorSet::new(vec![
            validator(0x01, 100),
            validator(0x02, 100),
            validator(0x03, 100),
        ]);
        let h = OpenHlHeight(7);
        let p1 = ctx.select_proposer(&set, h, Round::new(0)).address;
        let p2 = ctx.select_proposer(&set, h, Round::new(0)).address;
        assert_eq!(p1, p2);

        let p3 = ctx.select_proposer(&set, h.increment(), Round::new(0)).address;
        assert_ne!(p1, p3);
    }

    #[test]
    fn new_proposal_round_trips_fields() {
        let ctx = OpenHlContext;
        let addr = OpenHlAddress([0xaa; 20]);
        let value = OpenHlValue(BlockHash([0xbb; 32]));
        let proposal = ctx.new_proposal(
            OpenHlHeight(5),
            Round::new(1),
            value,
            Round::Nil,
            addr,
        );
        assert_eq!(ProposalTrait::height(&proposal), OpenHlHeight(5));
        assert_eq!(*ProposalTrait::value(&proposal), value);
        assert_eq!(*ProposalTrait::validator_address(&proposal), addr);
    }

    #[test]
    fn new_prevote_and_precommit_have_distinct_types() {
        let ctx = OpenHlContext;
        let addr = OpenHlAddress([0xaa; 20]);
        let vid: NilOrVal<BlockHash> = NilOrVal::Val(BlockHash([0xbb; 32]));
        let prevote = ctx.new_prevote(OpenHlHeight(5), Round::new(0), vid, addr);
        let precommit = ctx.new_precommit(OpenHlHeight(5), Round::new(0), vid, addr);
        assert_eq!(VoteTrait::vote_type(&prevote), VoteType::Prevote);
        assert_eq!(VoteTrait::vote_type(&precommit), VoteType::Precommit);
    }

    #[test]
    fn height_increment_and_decrement() {
        let h = OpenHlHeight::INITIAL;
        assert_eq!(h.as_u64(), 1);
        assert_eq!(h.increment().as_u64(), 2);
        assert_eq!(OpenHlHeight::ZERO.decrement(), None);
        assert_eq!(OpenHlHeight(5).decrement().unwrap().as_u64(), 4);
    }
}

h.increment()Height trait の default method(内部で increment_by(1))。

答え合わせ

cd ~/code/openhl-reference && git checkout 784785b
diff -ur ~/code/my-openhl/crates/consensus/src/types ./crates/consensus/src/types
diff -u ~/code/my-openhl/crates/consensus/src/context.rs ./crates/consensus/src/context.rs
git checkout main

doc/テスト順は違って OK。各型の shape、OpenHlValidatorSet::new の sort comparator、select_proposer の body はほぼ一致。

合格基準

cargo test -p openhl-consensus context::tests

5 テスト pass(validator-set sort / 決定的 proposer / proposal round-trip / prevote≠precommit / height 算術)。よくあるミス: sort が a.cmp(&b)(昇順)で順が逆 / increment_by でなく increment を impl / 型ファイルが context.rs 不在で参照エラー。

まとめ(3行)

  • Context は Malachite 所有の trait — 空 struct OpenHlContext が 10 sub-type に名前を付ける type-family。
  • OpenHlValidatorSet::new() が構築時 sort で「未 sort」を表現不能にする — canonical 順(power 降順→address 昇順)。
  • select_proposer = (height+round)%count が決定的 — sort 順 proposer-election protocol で、ズレると即 fork。