本文へ移動
← Solana の内部 — バイトから組み立てるネイティブな perp プログラム
Phase B — Solana 上の HL プリミティブ

perp 取引所を作る · Solana — ネイティブなプログラム 1 本

レッスン 6 / 15·CONTENT45 分100 XP
コース
Solana の内部 — バイトから組み立てるネイティブな perp プログラム
レッスンの役割
CONTENT
順序
6 / 15

第6章 — Vault Deposits で歩く CPI 内部

教材コード: programs/openhl-core/src/lib.rs(process_create_vault 892–1001 行、process_deposit 1008–1076 行)、scripts/create-vault/src/main.rs、scripts/deposit/src/main.rs、fuzz/basics/src/main.rs(§6.6 の solinv ハーネス)。 検証対象バージョン: solana-cpi 2.2.1、solana-program 2.3.0、SPL Token プログラム(TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA)。


§6.0 はじめに

Phase A はランタイム基礎を組み立てた。Phase B は HL プリミティブを組み立てる — そしてそれらはすべて他のプログラム、主に SPL Token と話す。注文配置のたびにトークンが動く。決済のたびにトークンが動く。出金のたびにトークンが動く。これらはどれも「本書のプログラム内で何かをする」ではなく、「SPL Token に何かを本書のプログラムの代わりにやらせる」だ。プログラム間のこの対話を cross-program invocation (CPI) と呼ぶ。所有者チェックの次に、本書のプログラムが行う最も結果の大きな振る舞いである。

本章では、必要な最も基本的な CPI を構築する: market のための SPL Token vault と、ユーザのトークンを vault に移す Deposit 命令。両者あわせて CPI 機構全体を実演する。

  • CreateVault は 2 連続の CPI を行う — System::create_account(vault PDA が invoke_signed で署名)と SPL Token InitializeAccount3(誰の署名も要らない、ただの invoke)。
  • Deposit は 1 つの CPI を行う — SPL Token Transfer。署名はユーザだが、PDA 経由ではなく、外側トランザクションからの署名者特権の延長で渡る。

終えるころには次のものを手にしている。

  1. invoke と invoke_signed を並べて読み、両者が文字どおり「シード引数があるか」だけが異なる同じ呼び出しであると理解する。
  2. ランタイムの署名者特権規則を歩く: 外側トランザクションの AccountMeta に現れた署名者が CPI の AccountInfo に再放出されると、被呼び出し側は署名者として扱う。
  3. SPL Token 命令を 2 つ手で組み立て — INITIALIZE_ACCOUNT_3(tag 18)と TRANSFER(tag 3) — ワイヤ上に乗るバイトを正確に把握する。
  4. なぜ vault 作成には invoke_signed を使い、入金には invoke を使うのかを理解する。両者は入れ替え不可だ — その選択は、その操作のために誰が署名しなければならないかが決める。

これが、プログラムを「自分のアカウントを所有する」段階から「チェーン上の他のすべてと話す」段階に進める章だ。Phase B のすべてはこの上に立つ。


§6.1 invoke と invoke_signed は同じ呼び出し

solana-cpi-2.2.1/src/lib.rs:137–139 を開こう。

pub fn invoke(instruction: &Instruction, account_infos: &[AccountInfo]) -> ProgramResult {
    invoke_signed(instruction, account_infos, &[])
}

これが invoke の本体すべて。invoke_signed を空のシードスライスで呼ぶだけだ。両者は内部で同じシステムコールで、唯一変わるのは PDA シードを供給するかどうか。

意味論上の区別は次のとおり。

  • invoke_signed(ix, accounts, signers_seeds) — 「この命令を実行せよ。これらのシードから派生する PDA を追加の署名者として扱え」。第 3 章で create_account 時に market PDA のためにプログラムが署名できるようにするのに使った。
  • invoke(ix, accounts) — 「この命令を実行せよ。本書から新たに署名者を追加しない。accounts 内にすでに存在する署名者(外側トランザクションに署名済み)は引き続き署名者として扱われる」。元のトランザクションの署名者がすでに必要な権限を持っているときに使う。

どちらを使うかは、CPI の操作のために誰が署名しなければならないかで完全に決まる。

  • create_account の新アカウント? 自前で所有する PDA。本書しか署名できない。→ invoke_signed。
  • SPL Token Transfer の transfer authority? ユーザのウォレット鍵ペア。外側トランザクションに署名済み。→ invoke。
  • SPL Token MintTo の mint authority? 場合による。本書のプログラムが mint authority なら(例: 自前で制御する PDA)、→ invoke_signed。ユーザが mint authority で外側 tx に署名しているなら、→ invoke。

第三の選択肢はない。すべての CPI は、外側トランザクションから署名者を継承するか、PDA シードで拡張するかのどちらかだ。ランタイムの仕事は、CPI が要求するすべての署名者が、どちらかの仕組みで説明できることを検証することだ。

SDK が隠していること: Anchor の CpiContext::new(...) と CpiContext::new_with_signer(...) は、それぞれ invoke と invoke_signed の直接ラッパだ。選択はあなたに委ねられる。Anchor が代わりに選ぶことはない。間違ったほうを選ぶと、ランタイムは署名エラーで CPI を失敗させる。

演習 §6.1. CPI が PDA 署名者を必要としないときに、空でないシードスライスで invoke_signed を呼ぶと何が起きるか。(ヒント: エラーにはならない。ランタイムは、内側命令の必要署名者リストに現れない PDA 署名者を単に無視する。)


§6.2 署名者特権の延長

Deposit を PDA 署名なしで成立させている規則は短い: 本書のプログラムの外側トランザクションの AccountMeta に署名者が現れ、CPI の AccountInfo でそれを再放出すると、被呼び出し側はそのアカウントを署名者として認識する。

これがランタイムの「署名者特権の延長」だ。ユーザは外側トランザクションで一度署名する。process_deposit 内では、user アカウントの AccountInfo の is_signer = true だ(外側 tx で署名されているから)。本書が SPL Token Transfer 命令を AccountMeta::new_readonly(*user_ai.key, true)(signer = true)で組み立て、invoke の account_infos に user_ai.clone() を渡すと、ランタイムは確認する: 「このアカウントは自分のレベルで署名者とマークされているか? はい。CPI のアカウントリストでも署名者とマークされているか? はい。両者は一致するか?」 — 一致すれば、被呼び出しプログラム(SPL Token)は、is_signer = true の AccountInfo をユーザについて受け取る。

この規則が防いでいること: CPI を通じて非署名者を署名者に昇格させることはできない。ユーザが外側トランザクションに署名していなければ、CPI 内で AccountMeta::new(*user.key, true) を何度書こうと署名は生まれない。ランタイムは食い違いを見つけ、MissingRequiredSignature で CPI を拒否する。

この規則が許していること: 署名を再度頼まずに署名者を伝播できる。ユーザは一度署名する。その署名は、CPI 宣言でユーザを署名者として再放出する以降の連鎖上のすべてのプログラムに対して有効だ。これがあって、1 つのユーザ署名で SPL Token トランスファ、スワップ、プログラム呼び出しの任意の連鎖を 1 トランザクションで認可できる。

もう 1 つある。invoke_signed の signers_seeds パラメータから来る PDA 署名者は、CPI の間だけ外側署名者集合に追加される。ランタイムはシード + 呼び出し元プログラム ID をハッシュし、結果が署名対象アカウントと一致することを確認し、そのアカウントを CPI の署名者として扱う。これが PDA に署名する唯一の仕組みだ — 秘密鍵はないのだから。

両方の機構 — 外側 tx 伝播と PDA シード — は同じ invoke_signed 本体に住んでいる。ランタイムは内側命令の署名者を判定するときに 2 つの和集合を取る。

演習 §6.2. process_deposit で、SPL Token Transfer 命令を AccountMeta::new_readonly(*user_ai.key, false)(signer = false)で組み立てると何が起きるか。トランスファは成功するか。なぜか。


§6.3 CreateVault を歩く — 2 連続 CPI

CreateVault は、両方の CPI 形式が同じハンドラに現れる場所だ。programs/openhl-core/src/lib.rs:892–1001 から。

PDA 派生(932–954 行)が 2 つの pubkey を準備する: [VAULT_SEED, market.key, mint.key] の vault_token_account と、[VAULT_AUTH_SEED, market.key] の vault_authority。第 3 章と同じ find_program_address 機構。bump が返り、vault bump は下で使う。authority bump はまだ不要だ(出金を追加する後の章で、プログラムが vault authority として署名する必要が出てきたときに重要になる)。

CPI 1 — System::create_account、956–975 行。

let create_ix = system_instruction::create_account(
    payer_ai.key,
    vault_ai.key,
    rent,
    TOKEN_ACCOUNT_LEN as u64,
    &SPL_TOKEN_PROGRAM_ID,
);
invoke_signed(
    &create_ix,
    &[payer_ai.clone(), vault_ai.clone(), system_ai.clone()],
    &[&[
        VAULT_SEED,
        market_ai.key.as_ref(),
        mint_ai.key.as_ref(),
        &[vault_bump],
    ]],
)?;

ここが invoke_signed なのは、新アカウントが自前所有の PDA であり、System が新アカウントの署名を要求するからだ。シード + bump は find_program_address 呼び出しで使ったものと同じ。create_account の第 3 引数は新アカウントの所有プログラム — SPL_TOKEN_PROGRAM_ID を渡し、自前プログラムは渡さない。この瞬間から vault token account は SPL Token が所有することになり(Solana ランタイムレベルで)、データを書けるのは SPL Token だけになる。

AccountInfo 配列に注目: [payer_ai, vault_ai, system_ai]。System プログラムが create_account で必要とするのはこの 3 つだけ(ハンドラの他のアカウント — market、mint、vault_authority、token_program — は System が必要としないので渡さない)。

CPI 2 — SPL Token InitializeAccount3、977–997 行。

let mut init_data = Vec::with_capacity(1 + 32);
init_data.push(spl_token_ix::INITIALIZE_ACCOUNT_3);
init_data.extend_from_slice(vault_auth_ai.key.as_ref());
let init_ix = Instruction {
    program_id: SPL_TOKEN_PROGRAM_ID,
    accounts: vec![
        AccountMeta::new(*vault_ai.key, false),
        AccountMeta::new_readonly(*mint_ai.key, false),
    ],
    data: init_data,
};
invoke(&init_ix, &[vault_ai.clone(), mint_ai.clone(), token_ai.clone()])?;

これがプレーン invoke なのは、誰も署名する必要がないからだ。vault アカウントはすでに SPL Token 所有だ(CPI 1 でそうした)。InitializeAccount3 はただのデータ書き込み — 空のトークンアカウントに mint と owner フィールドを設定する。SPL Token に所有されているという事実自体が SPL Token によるデータ書き込みを認可するので、署名は要らない。

invoke に渡される AccountInfo 配列は [vault_ai, mint_ai, token_ai] だが、命令の AccountMeta に挙がっているのは vault_ai と mint_ai だけ — なぜ追加の token_ai が要るのか。ランタイムは、呼び出し先プログラム自身の AccountInfo も渡すことを要求する。これが「AccountInfo 再借用」規則だ: ランタイムが呼び出しを準備するために必要となる AccountInfo はすべて渡さなければならず、それには被呼び出しプログラム自身が含まれる。

CPI 2 つ、ハンドラ 1 つ、両方の署名形式。このパターンは Phase B 全体にわたって繰り返される。

演習 §6.3. invoke 呼び出しから token_ai.clone() を削除せよ。ランタイムが返すエラーは何か。初期 Solana コードで最も頻繁に起きる CPI バグの 1 つで、最も Google しにくいエラーメッセージの 1 つだ。


§6.4 Deposit を歩く — ユーザは外側で署名

Deposit はより単純なケースだ。programs/openhl-core/src/lib.rs:1008–1076 から。

命令データ。

let mut transfer_data = Vec::with_capacity(1 + 8);
transfer_data.push(spl_token_ix::TRANSFER);
transfer_data.extend_from_slice(&amount.to_le_bytes());

合計 9 バイト: 1 バイトのタグ(Transfer は 3)と、リトルエンディアン 8 バイトの amount。これが SPL Token Transfer のワイヤフォーマット全体だ。

アカウント。

let transfer_ix = Instruction {
    program_id: SPL_TOKEN_PROGRAM_ID,
    accounts: vec![
        AccountMeta::new(*user_token_ai.key, false),
        AccountMeta::new(*vault_token_ai.key, false),
        AccountMeta::new_readonly(*user_ai.key, true),
    ],
    data: transfer_data,
};

3 アカウント: source トークンアカウント(書き込み可)、destination トークンアカウント(書き込み可)、authority(署名者、読み取り専用)。authority はユーザのウォレットで、署名者としてマークされている。ここではユーザのウォレットに書き込まない — トークンの移動はソーストークンアカウントの残高フィールドに反映され、ウォレットの lamports には反映されない — のでウォレット自身は read-only-but-signing になる。

CPI。

invoke(
    &transfer_ix,
    &[
        user_token_ai.clone(),
        vault_token_ai.clone(),
        user_ai.clone(),
        token_ai.clone(),
    ],
)?;

プレーン invoke。シードなし。ユーザは外側トランザクションに署名した。その署名が user_ai AccountInfo の is_signer = true フラグ経由で SPL Token に流れる。SPL Token は authority として受け入れる署名者を見て、トランスファが確定する。

これが「プログラム媒介ユーザトランスファ」の正典パターンだ: ユーザは外側トランザクションに署名して操作を認可する、プログラムは必要な CPI を組織化する、ユーザの署名者特権が流れていく。

演習 §6.4. user_token_account と vault_token_account が異なる mint に属する Deposit トランザクションを組み立てよ。どのエラーが、どの層 — 本書のプログラムか SPL Token か — で表面化するか。


§6.5 SPL Token 命令データを手で組み立てる

spl-token クレートは意図的にインポートしなかった。代わりに 2 命令分のバイトを手で組み立てた。lib.rs の 861–864 行。

mod spl_token_ix {
    pub const TRANSFER: u8 = 3;
    pub const INITIALIZE_ACCOUNT_3: u8 = 18;
}

これが必要な語彙のすべて。タグ値 2 つ。それ以外のすべて — フィールドエンコード、アカウント順 — は SPL Token プログラムのソース(あるいは spl-token クレートの Instruction enum、ただし意図的に依存していない)を読んで得る。

理由は 2 つ。

  1. 教育。 本章は CPI バイトについての章だ。正確なバイトを生成するビルダをインポートすれば、バイトを一度も見ずに章を終えられてしまう。一度手で組み立てればフォーマットを永続的に学べる — 将来出会うあらゆる SPL Token 命令で、ドキュメントを引かずにタグとデータが読める。
  2. バイナリサイズ。 spl-token クレートの最近のバージョンは、BPF コンパイル時に約 25 KB の依存関係(Token enum、エラー型、ヘルパビルダ)を引き連れてくる。2 命令しか必要としないプログラムにとっては純粋なオーバーヘッドだ。手で組み立てる方式はおそらく 200 バイト程度の追加で済む。

このトレードオフは大多数の本番プログラムはしない — 型安全性と保守ストーリーのために spl-token をインポートする。本書ではここでこのトレードを行う、章が要求するからだ。バイトを理解した後はトレードが逆向きになる: 25 KB を払い、型付きビルダにタグミスを捕捉させる。

この一般的な技法は、CPI する任意のプログラムに適用できる。

  1. 被呼び出し側のソースで命令タグを見つける。
  2. データフィールドを見つける(Borsh エンコード、または手作業詰め、プログラムによる)。
  3. 被呼び出し側プロセッサのアカウントリストを見つける — 通常はバリアントのドキュメントコメントに [WRITE, SIGNER] のようなアカウント参照として書かれている。
  4. Instruction 構造体を手で組み立てる。
  5. invoke または invoke_signed する。

この 5 ステップのレシピは SPL Token、Address Lookup Table プログラム、Compute Budget プログラム、BPF Loader プログラム、誰かが作った任意の独自プログラムに通用する。機構は変わらない。

Anchor が隠していること: Anchor は SPL Token のための型付き CPI ラッパ(anchor_spl::token::{Transfer, MintTo, ...})を持ち、バイトレイアウトを完全に隠す。動くが、本章があなたに見せたいまさにそれを覆い隠す。CPI を手で組み立て明示的に晒すネイティブプログラムは、anchor_spl::token::transfer(ctx, amount) を呼んでマクロを信用するプログラムより、はるかに優れたセキュリティ監査ストーリーを持つ。


§6.6 まとめと自己検証

まとめ図

                  invoke                            invoke_signed
                  ──────                            ─────────────
   実体:          invoke_signed(ix, accs, &[])      invoke_signed(ix, accs, seeds)
   PDA 署名:      なし                              あり(プログラムが派生 PDA に署名)
   外側署名者:    自動で伝播                        自動で伝播
   使い分け:      ユーザ / 外側 tx が操作に署名     自前所有の PDA が署名する必要


CPI ごとの特権解決:
    内側命令の各 AccountMeta(is_signer = true)について:
       同じ pubkey が**外側** AccountMeta で is_signer マーク?         ──► YES → 署名者 OK
       または、PDA としてこの pubkey を派生するシードを渡した?          ──► YES → 署名者 OK
       それ以外                                                          ──► CPI 拒否


openhl-core CPI マップ:

    CreateVault
       CPI 1: System::create_account
          invoke_signed、vault PDA シード
       CPI 2: SPL Token InitializeAccount3
          invoke(署名不要 — SPL Token が新アカウントを所有)

    Deposit
       CPI 1: SPL Token Transfer
          invoke(ユーザが外側 tx で署名、特権が流れる)

自分で検証する 3 項目

  1. invoke はシードなしの invoke_signed。 solana-cpi-2.2.1/src/lib.rs:137 を開き、3 行の本体を読め。両者の違いは「PDA シードを渡したか」だけだと身体化せよ。それ以外のすべては同じシステムコール。
  2. 被呼び出しプログラムの AccountInfo。 CreateVault の両 CPI で、invoke[_signed] に渡される accounts スライスは被呼び出しプログラムの AccountInfo を含む — CPI 1 では system_ai、CPI 2 では token_ai。これを忘れるのは初期 Solana で最も頻出のミスの 1 つで、ランタイムは紛らわしい AccountNotFound 風味のエラーを返す。lib.rs:966, 997 を読んで確認せよ。
  3. ユーザは一度署名し、SPL Token がそれを使う。 バリデータに対して deposit を実行せよ。ユーザ鍵ペアが外側トランザクションに署名する。SPL Token は is_signer = true(ユーザについて)の Transfer 命令を受け取る。トランスファが確定する。どこにも 2 度目の署名はない。1 署名、伝播。

検査を道具に試させる

CreateVault には検査が 7 つ、Deposit には 4 つある。第 3 章 §3.7 では solinv を CreateMarket に走らせ、消した検査はどれもランタイムが支えていた。この 2 つのハンドラは別のプログラムに CPI するので、検査を支えるものの候補が増え、支えるもののない検査も 1 つある。同じハーネス fuzz/basics/ に両方の仕様がある。payer や user の署名フラグを外すことと、各 slot を攻撃者が渡せる物に差し替えることだ(このプログラムが所有しない「market」、「mint」としてのトークン口座、PDA ではない vault のアドレス、vault の authority としての呼び出し側自身、各プログラムの slot に別のプログラム、A の払い出し元として B のトークン口座)。第 3 章 §3.7 のとおり準備してから:

cd fuzz/basics
cargo build --release --features invariant_ch6
cargo test --release --features invariant_ch6      # A は B のトークンを使えない
timeout -s INT 60 crucible run openhl_basics_fuzz invariant_ch6 \
    --binary-in target/release/invariant_test --timeout 45

今のプログラムでは [FUZZ_FINDING] の行は出ない。

演習 §6.6. 検査を 1 つずつ消し、作り直して走らせよ。走らせる前に予想すること。私たちが各 45 秒で測った結果を示す。最後の列は、差し替えた呼び出しを代わりに拒否したもので、ログから読んだ:

消した検査捕まったか代わりに呼び出しを拒否したもの
CreateVault: payer_ai.is_signerいいえランタイム: 権限のない署名者
CreateVault: market_ai.owner == program_idいいえ自前の vault の PDA の検査
CreateVault: mint_ai.owner == SPL Tokenいいえ自前の vault の PDA の検査
CreateVault: system_ai.key == Systemいいえランタイム: 命令に要るアカウントがない
CreateVault: token_ai.key == SPL Tokenいいえランタイム: 命令に要るアカウントがない
CreateVault: vault_ai.key == expected_vaultいいえランタイム: 権限のない署名者
CreateVault: vault_auth_ai.key == expected_authはい、account-swap、slot 4—
Deposit: user_ai.is_signerいいえランタイム: 権限のない署名者
Deposit: user_token_ai.owner == SPL Tokenいいえランタイム: 所有していないアカウントのデータを命令が変えた
Deposit: vault_token_ai.owner == SPL Tokenいいえランタイム: 所有していないアカウントのデータを命令が変えた
Deposit: token_ai.key == SPL Tokenいいえランタイム: 命令に要るアカウントがない

先を読む前に、ほかに何も支えていない行を 1 つ見つけよ。

ほとんどの行は §6.2 と §6.1 の繰り返しだ。プログラムは自分が受け取った署名しか渡せないので、payer や user の署名がなければ CPI で落ちる(§6.2)。ハンドラは内側の命令に呼び先の ID を書き込んで組む(SPL_TOKEN_PROGRAM_ID、system_program::ID)ので、slot に別のプログラムを入れても CPI の行き先は変わらず、ランタイムが本物の呼び先のアカウントを見つけられないだけだ(§6.1)。PDA ではないアドレスへの create_account には、そのアドレス自身の署名が要る。中身が同じで別のプログラムが所有するトークン口座は SPL Token を通り抜け、Transfer まで実行される。そのあとランタイムが拒否する。SPL Token が自分の所有していないアカウントを変えたからだ(第 1 章)。B の本物のトークン口座は SPL Token 自身が拒否する。A がその authority ではないからだ(owner does not match。テストで押さえてある)。

2 つの行は自前のコードが支えているが、それは fuzzer の差し替え方のせいでしかない。vault の PDA は market と mint から導くので、どちらか一方だけを差し替えると slot 3 が違うアドレスになる。それらから導くアカウントと一緒に組で差し替えると(account_swap::check_sets。第 12 章 §12.7 でも使う)、2 行は分かれる。mint の所有者検査を消しても、SPL Token がトークン口座を mint として認めない(Invalid Mint)。market の所有者検査を消すと呼び出しは通り、実は誰かのトークン口座である「market」のためのトークン口座が作られた。この検査は本当に仕事をしている。

覚えておくべきははいの行だ。vault_authority は作られることがない。ただのアドレスで、InitializeAccount3 が新しいトークン口座の持ち主として書き込む。CPI のどこも、それが PDA であることに頼っていない。だからプログラムが確かめなければ、呼び出し側は自分を指定でき、以後その市場の vault に入る預け入れはすべて呼び出し側が動かせるものになる。ランタイムにとってそのアドレスはただのデータなので、ランタイムの規則では捕まえられない。

もう 2 つ試せる。1 つ目、fuzz/basics/src/main.rs で Deposit の slot 2(送り先)の差し替え候補に self.b_ata を足して、もう一度走らせよ。A はどのトークン口座にも預け入れられるので、実行のたびに報告される。これは穴か。ここではそうではない。Deposit は何も記録しないので、A が B の口座に払うのは贈り物でしかない。送り先が意味を持つ帳簿づけは第 11・12 章で加わり、そこで送り先を縛る。検出とは、プログラムが許した呼び出しのことだ。それが問題かどうかは、プログラムが何を約束したかの問いである。

2 つ目は、第 3 章から持ち越した、読むときの問いだ。CreateVault は market の所有者を確かめるが、アドレスは確かめない。第 2 章の Initialize は今も、呼び出し側が作った任意のアカウントに Market を作れるので、同じ組に PDA ではない 2 つ目の市場がありうる。--features invariant_ch3_reader で作って走らせよ。このハーネスはそういう市場を作り、CreateVault にそれを読ませる(3 つの slot を一緒に差し替える。market と、そこから導く 2 つのアドレス)。今のプログラムで報告が出る。テスト create_vault_accepts_a_market_not_at_its_pda でも直接確かめられる。これは穴か。先を読む前に考えよ。

PDA が約束したのは、(base, quote) ごとにアドレスが 1 つ、ということだった。プログラムのほとんどはそれに頼っていない。市場に結びつくアカウント(トークン口座とその authority、板、オラクル、資金調達の状態、ポジション、trading vault、保険基金)はどれも市場自身のアドレスを seed にしているので、2 つ目の市場は別の島になる。それに CreateMarket でも、市場の authority は誰でも選べる。例外はどれも第 13 章にある。手数料の vault とその authority(quote の mint ごとに 1 つ、全市場で共有)と、builder の profile(builder ごとに 1 つ)だ。第 6〜12 章の範囲では、2 つ目の市場が別の市場のトークンに届く道は見つからなかった。手数料の vault から利用者の払った以上を取り出せるかどうかは、第 13 章 §13.6 で同じ道具を使って確かめる。答えは「できない」で、どの加算も同じトランザクションの中で払われているからだ。「その」市場を PDA を導いて見つけるクライアントは大丈夫だ。市場の形をしたアカウントなら何でも信じるクライアントは大丈夫ではない。組ごとに市場を 1 つに限りたいなら、CreateVault が市場自身の base_mint・quote_mint と保存された bump からアドレスを導き直せばよい(第 3 章の「bump の保存」)。試したところ、その検査を足すと組は拒否され(InvalidSeeds)、fuzzer は 45 秒で何も見つけなかった。


第 7 章への導線

これで他のプログラムと話せるようになった。しかし、Phase B で最も話す相手になるプログラムは自分自身 — オンチェーン板だ。CLOB は market アカウントの中に bids と asks のスラブとして住み、すべての place/cancel 命令はその大部分を読み書きする。そのデータ構造が次に組み立てるものだ。

第 7 章ではオンチェーン CLOB 設計を歩く: critbit vs heap vs slab、bytemuck 経由のゼロコピーアカウントアクセス、メモリ局所性とアカウントサイズのトレードオフ、本番プログラムがほぼ常に slab を選ぶ理由。Market 構造体を固定容量の板を埋め込むよう拡張し、そこへ書き込む place_order 命令を追加し、板が満ちていくにつれて CU 包絡線が締まる様子を観察する。終わるころには market が初めての注文を受け取り、Phase A のコンピュートバジェット章を駆動した問い —「200K CU でマッチャをどう収めるか」— が初めて切迫したものになる。