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

レッスン5 — read_best_bid がライブ状態を読む — current_best_bid() に差し替え

問い

レッスン4 で配管は通ったが read_best_bid はハードコードのまま。これを live state read にどう差し替えるか? そして cargo test の並列実行でプロセスグローバル CLOB_STATE が競合する問題をどう解くか?

原理(最小モデル)

  • 未インストール時 zero = 「未初期化 storage slot」のセマンティクス。 Solidity は STATICCALL の zero を「liquidity なし」と解釈し trade を控える。error にすると boot 中(install 前)の全 transaction が revert する。
  • constant-time precompile = gas 課金で state を漏らさない。 未インストール時だけ gas を減らすと、attacker が gas を測って validator の state を推測できる。CLOB_BASE_GAS_COST を一定に保つ。
  • cargo test 並列 → global 競合 → TEST_SERIALIZER が必要。 CLOB_STATE を触るテストが 2 個あると、並列実行で Some(clob)None の間を flap する。Mutex<()> で直列化。
  • serializer はモジュール単位 / uninstall はテストの 先頭 直列化スコープを狭く。panic したテストは cleanup を走らせない → 次のテスト先頭の reset が safety net。

具体例

out[24..32] への直接書き込み(u64 を u256 の右端 8 byte に zero-extend):

slot1: [00..00(24byte) | price BE 8byte]  ← out[24..32].copy_from_slice(&price.0.to_be_bytes())
slot2: [00..00(24byte) | qty   BE 8byte]  ← out[56..64].copy_from_slice(&qty.0.to_be_bytes())
上位 24 byte は vec![0u8; 64] の zero-init のまま(追加コスト 0 で zero-extend 成立)

24 と 56 は (32−8) と (64−8) の算数 — マジックでない。

失敗例(誤解)

U256::from(price.0).to_be_bytes::<32>().copy_from_slice(...) が明快」は誤り — 一時 [u8;32](うち 24 byte は zero)を allocate して 32 byte memcpy する。直接 out[24..32].copy_from_slice(&price.0.to_be_bytes()) なら 8 byte memcpy のみ(仕事半分、precompile は hot path)。「serial_test crate を使う」も誤り(global 1 つに mutex 1 行で済む)。


ここまでで「zero セマンティクス・constant gas・直列化」は着地した。ここから差し替える。コードは完全形(e2e proof はレッスン6)。

🛑 予測。 cargo test は並列実行。CLOB_STATE を read/write するテストが 2 つあるとき、直列化しないとどんな失敗モードか?(答え: flaky test。A が install して B が「CLOB なし→zero」を assert したいのに、B が A の install/uninstall の間に走ると A の CLOB を見て間違った値を assert。スケジューリング次第で 0〜30% failure。CI がランダム flake。TEST_SERIALIZER で 1 つずつ走らせて排除。)

ステップで組み立てる

Step 1: read_best_bid 本体を差し替え

#[allow(clippy::unnecessary_wraps)]
fn read_best_bid(_input: &[u8], _gas_limit: u64, _reservoir: u64) -> PrecompileResult {
    let mut out = vec![0u8; 64];

    if let Some((price, qty)) = current_best_bid() {
        // Big-endian u256: rightmost bytes carry the value.
        out[24..32].copy_from_slice(&price.0.to_be_bytes());
        out[56..64].copy_from_slice(&qty.0.to_be_bytes());
    }
    // If no CLOB is installed or there are no bids, `out` stays all zeros —
    // matches what an uninitialised perp market would return on mainnet.

    Ok(PrecompileOutput::new(CLOB_BASE_GAS_COST, Bytes::from(out), 0))
}

current_best_bid() を read、None なら short-circuit で out は zero のまま。price.0.to_be_bytes()[u8;8])を 32-byte word の右端(24..32)にコピー — 上位 24 byte は zero のまま = u64 の big-endian u256 encoding。ハードコードの out[31]=100/out[63]=10 は消える。doc コメントも「0 if no bid or no CLOB installed」に更新(コントラクトは「未インストール」と「empty book」を見分けられない、意図的)。

Step 2: テストモジュールに TEST_SERIALIZER

/// Tests in this module touch process-global `CLOB_STATE`. This mutex
/// serializes them so parallel test execution can't observe a torn state.
static TEST_SERIALIZER: Mutex<()> = Mutex::new(());

CLOB_STATE を触る各テストは冒頭で let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);unwrap_or_else(PoisonError::into_inner)死活問題 — これがないとテスト 1 つの panic で mutex が poison し以降全テストが落ちる(poison から復旧して「panic したが後続は走る」にする)。型推論が詰まる環境では unwrap_or_else(|e| e.into_inner()) の明示クロージャ形が安定。

Step 3: レッスン3 の 2 テストを更新(zero output を期待 + 直列化)

    /// With no CLOB installed, the precompile returns 64 zero bytes —
    /// matching what an uninitialised perp market would report on mainnet.
    #[test]
    fn read_best_bid_returns_zero_when_no_clob_installed() {
        let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
        uninstall_clob();

        let result = read_best_bid(&[], 100_000, 0).expect("precompile must not error");
        assert_eq!(result.bytes.len(), 64);
        let price = U256::from_be_slice(&result.bytes[0..32]);
        let qty = U256::from_be_slice(&result.bytes[32..64]);
        assert_eq!(price, U256::ZERO);
        assert_eq!(qty, U256::ZERO);
        assert_eq!(result.gas_used, CLOB_BASE_GAS_COST);
    }

registered_precompile_is_invokable_via_registry も同様に冒頭で TEST_SERIALIZER 取得 + uninstall_clob() を追加し、assert_eq!(price, U256::ZERO) に変更:

    #[test]
    fn registered_precompile_is_invokable_via_registry() {
        let _g = TEST_SERIALIZER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
        uninstall_clob();

        let extended = openhl_precompiles(Precompiles::cancun());
        let precompile = extended
            .get(&CLOB_READ_BEST_BID)
            .expect("CLOB precompile must be registered");
        let result = precompile
            .execute(&[], 100_000, 0)
            .expect("call must not error");
        assert_eq!(result.bytes.len(), 64);
        // No CLOB → zero output.
        let price = U256::from_be_slice(&result.bytes[0..32]);
        assert_eq!(price, U256::ZERO);
    }

uninstall はテスト先頭(panic したテストは末尾 cleanup を走らせない → 次のテスト先頭の reset が safety net、idempotent なので常に安全)。真ん中の openhl_precompiles_registers_clob_addressCLOB_STATE を触らないので serializer/uninstall を加えない(不要な直列化)。

答え合わせ

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

本レッスン後、Stage 9b に かなり近い(本体・TEST_SERIALIZER・更新 2 テスト)。残る差は read_best_bid_returns_live_state_*(レッスン6)。

合格基準

cargo test -p openhl-evm --release

42 テスト pass(precompile を触る 4 テストのうち 2 つが直列化、修正 2 テストが zero output を assert)。よくあるミス: Mutex を tokio から import(std::sync::Mutex が正)/ _g の取得が uninstall_clob() の後 / use openhl_clob::{Order, ...} を消す(レッスン6 で使う、unused 警告は無害)。

まとめ(3行)

  • 未インストール時は zero を返す(error でなく)— 未初期化 perp market のセマンティクス、boot 中の revert を避ける。
  • u64 → u256 は右端 8 byte に直接コピー(out[24..32])— 中間 [u8;32] を確保しない hot path 最適化。
  • TEST_SERIALIZER(モジュール単位 Mutex<()>)+ テスト先頭の uninstall_clob() で並列 cargo test の global 競合を排除。