レッスン9 — LiveRethEvmBridge に CLOB + submit_order を持たせる
問い
完成した matching engine を consensus 側の LiveRethEvmBridge にどう接続するか? CLOB は Reth EVM の 中 に置くのか、横 に置くのか?
原理(最小モデル)
- CLOB は EVM の 横 に置く。
clob: Mutex<Book>はprovider/stateと並ぶ bridge のフィールド。約定は payload に併走する parallel データレーンで、まだ EVM transaction ではない(それは Step 3 の precompile)。 - Mutex は 1 つでなく 2 つ。
clobとpending_fillsは別タイミングで別 caller が変更する。lock を分ければpending_fill_countを読むスレッドが book を触る submitter を block しない。 - interior mutability +
&selfが async 共有 state の idiom。submit_order(&self, ...)だから bridge をArcで共有できる。RwLock<Bridge>を上に載せると全アクセスが直列化する。 - lock を取る API は lock 越しに参照を返さない。
payload_fillsは&[Fill]でなくVec<Fill>(clone) を返す(borrow を返すと caller が lock guard を抱え続けて deadlock)。
具体例
bridge 内部トポロジー(3 レーン):
order in → submit_order → [Mutex<Book>] match → fills → [Mutex<Vec<Fill>>] append
↓ build_payload が drain(レッスン10)
[Mutex<State> pending: HashMap<id, (hash, header, Vec<Fill>)>]
↓ build_payload → PayloadId
EVM レーン(state/header/forkchoice)
clob と pending_fills を別 Mutex にするのが load-bearing — 2 レーンが直列化しない。
失敗例(誤解)
「Mutex<(Book, Vec<Fill>)> 1 個でいい」は誤り — submit ごとに matching と fill-buffer mutation の両方で lock を保持し、submit せず count を読むだけのコードが block される。「submit_order を &mut self に」も誤り — Arc<RwLock<Bridge>> が必要になり全 access を 1 グローバル lock で直列化する。
ここまでで「横に置く・Mutex 2 個・&self」は着地した。ここから組み立てる。コードは完全形(build_payload の drain はレッスン10)。
🛑 予測。 本レッスン後、order を submit して fill が蓄積したあと
build_payloadを呼ぶと、その payload へのpayload_fills(id)は何を返すか?(答え:Some(vec![])— 空。本レッスンはデータフローを接続するがbuild_payloadはまだ drain せず空 Vec を挿入。レッスン10 でこれがSome(vec![fill...])になる。)
ステップで組み立てる
Step 1: crates/evm/Cargo.toml に dep
[dependencies]
openhl-consensus = { workspace = true }
openhl-types = { workspace = true }
openhl-clob = { workspace = true } # NEW
async-trait = { workspace = true }
# ... rest unchanged ...
Step 2: live_node.rs に import
use openhl_clob::{Book, Fill, FillResult, Order}; // NEW
Book(matching engine)/ Fill(output)/ FillResult(Book::submit の wrapper)/ Order(input)を pull in。
Step 3: struct にフィールド 2 個(validator と state の間)
#[derive(Debug)]
pub struct LiveRethEvmBridge<P> {
provider: P,
chain_spec: Arc<ChainSpec>,
validator: EthBeaconConsensus<ChainSpec>,
clob: Mutex<Book>, // NEW
pending_fills: Mutex<Vec<Fill>>, // NEW
engine_handle: Option<ConsensusEngineHandle<EthEngineTypes>>,
state: Mutex<State>,
}
clob は matching engine(Book は内部 thread-safe でないので Mutex で包む)。pending_fills は submit_order が push しレッスン10 の build_payload が drain する buffer。別 Mutex にするのは lock 粒度(submit は matching で clob を、append で pending_fills を短く保持。分ければ 2 submit が全 chain を直列化しない)。
Step 4: State の pending を 3-tuple に
#[derive(Debug, Default)]
struct State {
next_payload_id: u64,
/// Pending payloads keyed by `PayloadId.0`. Value is (`block_hash`, `header`,
/// fills drained from the CLOB at `build_payload` time).
pending: HashMap<u64, (B256, Header, Vec<Fill>)>,
chain: HashMap<B256, Header>,
head: Option<B256>,
}
3 番目要素が payload ごとの約定リスト。chain は据え置き(commit 後の fill track は本コース範囲外)。
Step 5: new() を更新
pub fn new(provider: P, chain_spec: Arc<ChainSpec>) -> Self {
let validator = EthBeaconConsensus::new(Arc::clone(&chain_spec));
Self {
provider,
chain_spec,
validator,
clob: Mutex::new(Book::new()), // NEW
pending_fills: Mutex::new(Vec::new()), // NEW
engine_handle: None,
state: Mutex::new(State::default()),
}
}
Step 6: メソッド 3 個
/// Submit an order to the CLOB. Resulting fills are buffered in
/// `pending_fills` until the next `build_payload` drains them.
pub fn submit_order(&self, order: Order) -> FillResult {
let mut book = self.clob.lock().expect("clob mutex poisoned");
let result = book.submit(order);
if !result.fills.is_empty() {
self.pending_fills
.lock()
.expect("pending_fills mutex poisoned")
.extend(result.fills.iter().copied());
}
result
}
/// Inspect (read-only) the fills attached to a built payload. Returns
/// `None` if the payload id is unknown. Production code would encode
/// these as EVM-executable transactions before they reach the block
/// body; v0 keeps them as a parallel list for test inspection.
#[must_use]
pub fn payload_fills(&self, id: PayloadId) -> Option<Vec<Fill>> {
let s = self.state.lock().expect("state mutex poisoned");
s.pending.get(&id.0).map(|(_, _, fills)| fills.clone())
}
/// Number of fills currently buffered, waiting for the next `build_payload`.
#[must_use]
pub fn pending_fill_count(&self) -> usize {
self.pending_fills
.lock()
.expect("pending_fills mutex poisoned")
.len()
}
3 つとも &self(interior mutability)。submit_order は write path — Rust の NLL により book.submit(order) 直後に clob guard が drop され、pending_fills.lock() 時点で clob は解放済み(2 lock は同時に保持しない、deadlock なし)。payload_fills は &[Fill] でなく Vec を clone して返す(lock 越しに参照を返さない)。
Step 7: destructuring の波及更新(5 サイト)
build_payload/payload_ready/validate_payload/commit/payload_fills の pending tuple pattern を 3 要素に揃える:
// build_payload: s.pending.insert(id, (hash, header, Vec::new())); // 今は空 Vec; レッスン10 で drain
// payload_ready: let (hash, header, _fills) = s.pending.get(&n).cloned()...
// validate_payload / commit: .find(|(h, _, _)| *h == ...).map(|(_, h, _)| h.clone())
cargo check が「pattern length 2 but expected 3」で見逃しを教える。build_payload の Vec::new() は placeholder(レッスン10 で std::mem::take に置換)— TODO コメントより discoverable。
答え合わせ
cd ~/code/openhl-reference && git checkout 428cc26
diff -u ~/code/my-openhl/crates/evm/src/live_node.rs ./crates/evm/src/live_node.rs
git checkout main
本レッスン後、フィールド/メソッド/3-tuple は参照と一致。build_payload の drain(参照は std::mem::take)と integration test はレッスン10/11。
合格基準
cargo test -p openhl-evm --release
→ 38 テスト pass(Consensus 由来、新規テストなし — 構造変更のみ)。bridge.pending_fill_count() == 0(fresh bridge)を smoke check できる。よくあるミス: dep 追加忘れ / 波及 site 見逃し(compiler が name する)/ Book を .await 跨ぎで保持(Send エラー)。
まとめ(3行)
- CLOB は EVM の横の parallel レーン(
clob: Mutex<Book>+pending_fills: Mutex<Vec<Fill>>、別 Mutex で lock 粒度を確保)。 submit_orderは&self+ interior mutability で Arc 共有可能、lock 越しに参照を返さない。build_payloadは今は空 Vec placeholder — drain はレッスン10、証明はレッスン11。