第6章 — Vault Deposits で歩く CPI 内部
教材コード:
programs/openhl-core/src/lib.rs(process_create_vault892–1001 行、process_deposit1008–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 経由ではなく、外側トランザクションからの署名者特権の延長で渡る。
終えるころには次のものを手にしている。
invokeとinvoke_signedを並べて読み、両者が文字どおり「シード引数があるか」だけが異なる同じ呼び出しであると理解する。- ランタイムの署名者特権規則を歩く: 外側トランザクションの
AccountMetaに現れた署名者が CPI のAccountInfoに再放出されると、被呼び出し側は署名者として扱う。 - SPL Token 命令を 2 つ手で組み立て —
INITIALIZE_ACCOUNT_3(tag 18)とTRANSFER(tag 3) — ワイヤ上に乗るバイトを正確に把握する。 - なぜ 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 つ。
- 教育。 本章は CPI バイトについての章だ。正確なバイトを生成するビルダをインポートすれば、バイトを一度も見ずに章を終えられてしまう。一度手で組み立てればフォーマットを永続的に学べる — 将来出会うあらゆる SPL Token 命令で、ドキュメントを引かずにタグとデータが読める。
- バイナリサイズ。
spl-tokenクレートの最近のバージョンは、BPF コンパイル時に約 25 KB の依存関係(Token enum、エラー型、ヘルパビルダ)を引き連れてくる。2 命令しか必要としないプログラムにとっては純粋なオーバーヘッドだ。手で組み立てる方式はおそらく 200 バイト程度の追加で済む。
このトレードオフは大多数の本番プログラムはしない — 型安全性と保守ストーリーのために spl-token をインポートする。本書ではここでこのトレードを行う、章が要求するからだ。バイトを理解した後はトレードが逆向きになる: 25 KB を払い、型付きビルダにタグミスを捕捉させる。
この一般的な技法は、CPI する任意のプログラムに適用できる。
- 被呼び出し側のソースで命令タグを見つける。
- データフィールドを見つける(Borsh エンコード、または手作業詰め、プログラムによる)。
- 被呼び出し側プロセッサのアカウントリストを見つける — 通常はバリアントのドキュメントコメントに
[WRITE, SIGNER]のようなアカウント参照として書かれている。 Instruction構造体を手で組み立てる。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 項目
invokeはシードなしのinvoke_signed。solana-cpi-2.2.1/src/lib.rs:137を開き、3 行の本体を読め。両者の違いは「PDA シードを渡したか」だけだと身体化せよ。それ以外のすべては同じシステムコール。- 被呼び出しプログラムの AccountInfo。 CreateVault の両 CPI で、
invoke[_signed]に渡されるaccountsスライスは被呼び出しプログラムの AccountInfo を含む — CPI 1 ではsystem_ai、CPI 2 ではtoken_ai。これを忘れるのは初期 Solana で最も頻出のミスの 1 つで、ランタイムは紛らわしいAccountNotFound風味のエラーを返す。lib.rs:966, 997を読んで確認せよ。 - ユーザは一度署名し、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 でマッチャをどう収めるか」— が初めて切迫したものになる。