レッスン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 が fork。sort 順 = 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-types が Context trait + 10 sub-trait を定義。-signing-ed25519 の rand 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_by は saturating_add(overflow panic 回避)、decrement_by は checked_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 に乗る。だが Context は ProposalPart 型を要求するので実体化しない 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 = ())。NilOrVal(Option でない)は 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 回避、% count で index < 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 — 空 structOpenHlContextが 10 sub-type に名前を付ける type-family。OpenHlValidatorSet::new()が構築時 sort で「未 sort」を表現不能にする — canonical 順(power 降順→address 昇順)。select_proposer = (height+round)%countが決定的 — sort 順 が proposer-election protocol で、ズレると即 fork。