FABRKNT
Step 3. Precompiles — EVM 拡張による CLOB ステートのスマートコントラクト連携
Read precompile
レッスン 5 / 12·CONTENT35 分70 XP
コース
Step 3. Precompiles — EVM 拡張による CLOB ステートのスマートコントラクト連携
レッスンの役割
CONTENT
順序
5 / 12

レッスン4 — install_clob() — EVM の state をマッチングエンジンに橋渡しする

問い

precompile は関数ポインタ(fn(&[u8], u64, u64) -> PrecompileResult)で、環境をキャプチャできない(move クロージャが書けない)。では bridge が所有する live CLOB state を、どうやって precompile に届けるか?

原理(最小モデル)

  • 関数ポインタ → プロセスグローバル state が回避策。 共有 state を static に置き、precompile が呼び出し時にそこを読む。bridge が install_clob で書き、precompile が current_best_bid() で読む。
  • RwLock<Option<Arc<Mutex<Book>>>> — アクセスパターンが違えばロックの種類も違う。RwLock は installed/uninstalled の区別(write 稀)、内 Mutex は matching engine(write 頻繁)。Mutex<Option<...>> 1 個だと全 read が 1 箇所のボトルネックを通る。
  • Arc<Mutex<Book>> で bridge/precompile 境界を越えて所有を共有。 別々の caller が同じ Book を見る。Arc =「所有者は複数、データは同じ」。
  • install は replace(error にしない)/ 配管は通すが電流は流さない。 test が install/uninstall を反復する。レッスン4 は配管(static / install 関数 / bridge フィールド型)を繋ぐが read_best_bid はハードコードのまま — スイッチはレッスン5。

具体例

4 層ラッパー RwLock<Option<Arc<Mutex<Book>>>> の責務分担:

① RwLock     : install 済みか否か(write=install/uninstall 超レア、read=precompile 毎回・並列OK → RwLock)
② Option     : install 前(None)/後(Some) を型で表現(None→precompile はゼロ encode)
③ Arc        : bridge と static が同じ Book を強参照(所有共有)
④ Mutex<Book>: matching engine 本体の排他保護(submit/best_bid_with_qty、write 頻繁 → Mutex)

bridge: submit_order → .lock().submit/precompile: current_best_bid → .lock().best_bid_with_qty — 同じ Book。

失敗例(誤解)

OnceLock/lazy_static! を使えばいい」は誤り — OnceLock は 1 回しか set できず、test 分離のための install/uninstall 反復に不向き。Rust 1.63+ の static RwLock = RwLock::new(None)(const fn)が標準イディオム。「Mutex<Option<...>> 1 個に統合」も誤り(全 read がボトルネック)。


ここまでで「process-global static・4 層の責務分担」は着地した。ここから配管を組み立てる(read_best_bid 本体はレッスン5)。コードは完全形。

🛑 予測。 PrecompileFn は関数ポインタで環境キャプチャ不可。precompile にインスタンスごとの state を渡す唯一の方法は?(答え: Arc<Mutex<Book>> を引数で渡せない(シグネチャ固定)→ static から読む。bridge が install で書き、precompile が読む。トレードオフ: プロセスあたり CLOB は 1 つに固定 — 単一バリデータでは受容可能。)

ステップで組み立てる

Step 1: Bookbest_bid_with_qty + best_ask_with_qtycrates/clob/src/book.rs

    /// Best bid price + total qty resting at that price level (sum of every
    /// resting order in the level's FIFO queue). Returns `None` if there
    /// are no bids.
    #[must_use]
    pub fn best_bid_with_qty(&self) -> Option<(Price, Qty)> {
        self.bids.iter().next().map(|(rev_price, queue)| {
            let qty: u64 = queue.iter().map(|o| o.qty.0).sum();
            (rev_price.0, Qty(qty))
        })
    }

    /// Best ask price + total qty resting at that price level.
    #[must_use]
    pub fn best_ask_with_qty(&self) -> Option<(Price, Qty)> {
        self.asks.iter().next().map(|(price, queue)| {
            let qty: u64 = queue.iter().map(|o| o.qty.0).sum();
            (*price, Qty(qty))
        })
    }

best_bid()(価格のみ)と違い (price, そのレベルの FIFO キュー内 qty 合計) を返す — precompile の 64-byte 2 値レスポンス用。depth_bid()(全 bid の注文本数)とは別メトリクス。

Step 2: precompiles/mod.rs の import

use openhl_clob::Book;
use std::sync::{Arc, Mutex, RwLock};

RwLock は read(precompile 毎回)が write(install プロセス 1 回)より圧倒的多なので並行 read を許す。

Step 3: static CLOB_STATE

/// Process-global handle to the CLOB the precompile reads from.
///
/// `None` until [`install_clob`] is called (typically by `LiveRethEvmBridge::new`).
/// While `None`, `read_best_bid` returns zero-encoded output rather than
/// erroring — this keeps existing tests deterministic and matches what an
/// uninitialised perp market would return on mainnet.
static CLOB_STATE: RwLock<Option<Arc<Mutex<Book>>>> = RwLock::new(None);

RwLock::new(None)const fn でコンパイル時評価 → 実行時の初期化レースが起きない。None は「未インストール」= ゼロ encode(メインネットの未初期化 perp market と同じ契約)。

Step 4: 3 つのモジュール関数

/// Install the CLOB instance the precompile should read from. The bridge
/// shares its `Arc<Mutex<Book>>` with the global so every EVM-side
/// `staticcall` to `CLOB_READ_BEST_BID` sees the same book the application
/// writes to via `submit_order`.
///
/// Calling this replaces any previously-installed CLOB. Production deployments
/// should call it exactly once at bridge construction.
pub fn install_clob(clob: Arc<Mutex<Book>>) {
    *CLOB_STATE.write().expect("CLOB_STATE rwlock poisoned") = Some(clob);
}

/// Clear the installed CLOB. Used by tests that need a clean slate; rare in
/// production. Idempotent — uninstalling when nothing is installed is a no-op.
pub fn uninstall_clob() {
    *CLOB_STATE.write().expect("CLOB_STATE rwlock poisoned") = None;
}

/// Read the currently-installed CLOB's best bid. Returns `None` if no CLOB
/// is installed or if the book has no bids. Public so tests can verify
/// install/uninstall without going through the precompile dispatch.
#[must_use]
pub fn current_best_bid() -> Option<(openhl_clob::Price, openhl_clob::Qty)> {
    let state = CLOB_STATE.read().expect("CLOB_STATE rwlock poisoned");
    let clob = state.as_ref()?;
    let book = clob.lock().expect("clob mutex poisoned");
    book.best_bid_with_qty()
}

install_clob は直前を置き換え(idempotent)、uninstall_clob は主に test 用、current_best_bid は EVM を経由せず直接テストできるよう公開。ロック順序の不変条件: 常に外(CLOB_STATE RwLock)→内(Book Mutex) の順(逆順を作らない限り deadlock しない)。

Step 5: bridge の clobArc<Mutex<Book>> に(live_node.rs

struct フィールドと new() を変更:

pub struct LiveRethEvmBridge<P> {
    provider: P,
    chain_spec: Arc<ChainSpec>,
    validator: EthBeaconConsensus<ChainSpec>,
    /// `Arc<Mutex<Book>>` rather than `Mutex<Book>` so the bridge can share
    /// its CLOB with the precompile module's process-global state. The bridge
    /// writes via `submit_order`; smart contracts read via the
    /// `clob_read_best_bid` precompile — both touch the same `Book`.
    clob: Arc<Mutex<Book>>,
    pending_fills: Mutex<Vec<Fill>>,
    state: Mutex<State>,
}

impl<P> LiveRethEvmBridge<P> {
    #[must_use]
    pub fn new(provider: P, chain_spec: Arc<ChainSpec>) -> Self {
        let validator = EthBeaconConsensus::new(Arc::clone(&chain_spec));
        let clob = Arc::new(Mutex::new(Book::new()));

        // Make our CLOB visible to the `clob_read_best_bid` precompile so
        // smart contracts can query live orderbook state. The bridge writes
        // (submit_order), the EVM reads (precompile); they share the same Arc.
        crate::precompiles::install_clob(Arc::clone(&clob));

        Self {
            provider,
            chain_spec,
            validator,
            clob,
            pending_fills: Mutex::new(Vec::new()),
            state: Mutex::new(State::default()),
        }
    }

Arc::clone(&clob) で refcount をインクリメント(bridge と static の両方が強参照)。submit_orderself.clob.lock()Arc<Mutex<Book>>&Mutex<Book> に deref するのでそのまま動く(他の callsite 変更不要)。

答え合わせ

cd ~/code/openhl-reference && git checkout b635ef7
diff -u ~/code/my-openhl/crates/clob/src/book.rs ./crates/clob/src/book.rs
diff -u ~/code/my-openhl/crates/evm/src/precompiles/mod.rs ./crates/evm/src/precompiles/mod.rs
git checkout main

本レッスン後、Stage 9b に 部分的に 一致(新メソッド/static/関数3/bridge フィールド)。残る差: read_best_bid がまだハードコード(レッスン5)+ レッスン3 の unit test がまだハードコード値を期待。

合格基準

cargo test -p openhl-evm --release

42 テスト pass(レッスン3 の unit test は依然ハードコード値を期待 — read_best_bid 未変更だから)。配管は通したが電流はまだ流れない。よくあるミス: self.clob.deref().lock() と書く(self.clob.lock() が正)/ struct リテラルで clob を使い忘れ。

まとめ(3行)

  • 関数ポインタ制約への定石は process-global static — bridge が install で書き、precompile が読む(コストは CLOB 1 つ/プロセス)。
  • RwLock<Option<Arc<Mutex<Book>>>> の 4 層は別々の責務(installed 区別 / 型表現 / 所有共有 / engine 保護)— 重ねるでなく分担。
  • レッスン4 は配管(static/install/Arc 共有)を通すが read_best_bid はハードコードのまま — スイッチはレッスン5。