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

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

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

第13章 — プロトコル プリミティブとしての Builder Codes

教材コード: crates/state/src/lib.rs(BuilderProfile)、programs/openhl-core/src/lib.rs(process_register_builder、process_place_order_with_builder、process_claim_builder_fees、process_create_fee_vault)、scripts/builder/src/main.rs、fuzz/fees/src/main.rs(§13.6 の solinv ハーネス)。

部品化に伴い、この章で読むロジックは crates/fees/src/lib.rs(openhl-fees)に移り、プログラムはそれを呼び出す。以下に引用するコードは分割前の programs/openhl-core/src/lib.rs の版で、行番号は追従していない。


§13.0 はじめに — builder code とは何で、何でないか

Builder code は取引に付与されるフロントエンド単位の識別子だ。ユーザがフロントエンド経由で注文を開くと、フロントエンドは builder code をトランザクションに含める。プログラムは取引のプロトコル手数料の設定可能な一部を、その builder のオンチェーン アカウントに credit する。

この語は Hyperliquid のものだ。builder(フロントエンド)がユーザの代わりに送った約定から手数料を受け取る。上限は perp で 0.1%、spot で 1% で、ユーザが builder ごとに承認した上限まで(Hyperliquid のドキュメント)。フロントエンドが自分で板を運営せずに注文フローから稼げる。取引所の「ホワイトラベル ルーティング」、伝統的金融の「紹介ブローカー」と同じ系譜 — 長く存在する分配インセンティブの Solana 版だ。

builder code は次のものではない:

  • 紹介コード (Referral codes)。 紹介コードは新規ユーザを紹介した人に報酬を与える。通常、サインアップごとに 1 回、あるいは紹介相手の手数料の長い尾を比率で永遠に支払う。Builder codes は取引ごとに支払い、誰が誰を紹介したかは追跡しない — ルーティングに報い、紹介に報いるのではない。
  • メーカー/テイカー リベート。 メーカー リベートはユーザ(指値注文を出した人)に自分の手数料の一部を戻す。Builder codes はユーザの手数料の一部を第三者(フロントエンド)に支払う。ユーザはどちらにせよ同じ総手数料を払う。違うのは分配を誰が受け取るかだ。

本章は 3 つの取引命令と 1 つのブートストラップを出荷する。

  1. RegisterBuilder — 各 builder が、累積手数料と自己宣言した最大シェアを保持する builder ごとの BuilderProfile PDA を作る。プログラムが credit するアカウントが必要なので登録が必要。アカウントなし、手数料なし。
  2. PlaceOrderWithBuilder — 最後のアカウントとして builder profile を取る取引命令バリアント。プロトコル手数料を計算して per-(quote_mint) fee vault にエスクローし、builder のシェアを profile の accumulated_fees に分割し、PlaceOrderChecked と同じ place-order パスを走らせる。
  3. ClaimBuilderFees — builder の引き出し呼び出し。アキュムレータをゼロ化し、PDA 署名付きで fee vault から builder のトークン アカウントへ claim 額の SPL Token Transfer を行う。
  4. CreateFeeVault — quote mint ごとの 1 回限りのブートストラップ。place-order バリアント実行前にクラスタごと 1 回走らせる。per-(quote_mint) fee_vault トークン アカウントを [b"fee_vault_auth", quote_mint] PDA が所有する形で確保する。第 6 章の CreateVault のミラー。

本章は PlaceOrderChecked も拡張して同じプロトコル手数料をエスクローする。理由は §13.5b の設計選択 #2 — 対称性だ。builder パスだけが手数料をエスクローすると、builder 経由の取引が経由なし取引より実質的に高くつく。本番デプロイは最初からこれを両方のバリアントで普遍的なプロトコル手数料として扱って解決する。

本章の知的内容は 3 部構成だ。§13.4 のアトミシティ論(なぜ手数料分割は取引命令の中で起き、別の「取引ごとに claim」呼び出しではないか)、§13.2 のキャップ積み重ねパターン(自己宣言キャップとプロトコル レベル キャップがどう相互作用し、builder が侵害されても手数料漏れを境界付けるか)、そして §13.5b の本番エスクロー設計議論(なぜ builder 手数料は第 11 章・第 12 章の二者間移動と構造的に異なるか — fee-vault 実装の背後にある 4 つの設計選択)。


§13.1 BuilderProfile アカウント

crates/state/src/lib.rs から:

#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct BuilderProfile {
    pub discriminator: [u8; 8],
    pub bump: u8,
    pub _pad0: [u8; 7],
    pub builder: [u8; 32],          // builder の pubkey
    pub max_fee_share_bps: u64,     // 自己キャップ、登録時にクランプ
    pub accumulated_fees: u64,      // claim 対象
    pub total_volume: u64,          // 統計: この builder 経由でルートされた base size
    pub fee_mint: [u8; 32],         // set at registration; fees are earned and claimed only in it
}

104 バイト。設計を担うフィールド 4 つ(fee_mint は下の制約で扱う):

builder はフロントエンド / アグリゲータの pubkey。PDA 派生での唯一のシードに使う: [BUILDER_PROFILE_SEED, builder.key]。これですべての Solana pubkey は高々 1 つの builder profile を持ち、pubkey を知っている誰もが派生できる。Profile は openhl-core 所有(本プログラムだけが accumulated_fees を変更できる)だが、誰でも公開フィールド(builder、total_volume)を読み、builder の主張するルーティング量を検証できる。

max_fee_share_bps は builder の自己宣言した、プロトコル手数料のどの fraction を取るかの上限。max_fee_share_bps = 2000 で登録した builder は、ルートされる任意の注文でプロトコル手数料の最大 20% を取ると公にコミットしている。これは信用シグナル — 「20% は我々、80% はプロトコル」と「50/50」を宣伝する builder は、ユーザにフロントエンドがどうマネタイズするかを伝える。自己キャップが低いほど → ユーザに優しい手数料分割 → 潜在的に多いフロー。

accumulated_fees は builder に credit され claim を待つ手数料の走行合計。このプロファイル経由でルートする PlaceOrderWithBuilder ごとにインクリメント。ClaimBuilderFees でゼロにリセット。Builder はプログラム アカウント内に蓄積しバッチで引き出す — 取引ごとに claim するよりはるかに安価だ。

total_volume は観測性 — builder 経由でルートされた base size。Builder が潜在パートナーに自分のフローを証明するため(あるいはユーザが builder の実績を吟味するため)使う。プログラム ロジックには使わない。

注目すべき設計制約 2 つ:

  • Builder あたり 1 profile。 PDA 派生が (builder pubkey) → (profile pubkey) マッピングを全単射にする。Builder は market ごとに異なる手数料分割の 2 profile を持てない。A/B テストしたいなら 2 つの異なる builder ウォレットを使う。
  • Quote 単位での手数料蓄積。 accumulated_fees はプロトコル手数料が取られる quote 通貨で計上される。複数 quote 通貨(USDC と USDT)なら、設計は (builder, mint) ごとの profile が必要、builder ごとだけではなく。ここは意図的に単一 quote に保つ、プログラムの残りも単一 quote なので。プログラムはこれを強制する。誰でも任意の quote mint で market を作れるので、builder が RegisterBuilder(アカウント 3、builder が署名)で fee_mint を決め、別の mint で quote された market での PlaceOrderWithBuilder は拒否し、ClaimBuilderFees は fee_mint の fee vault からしか払わない。この固定がないと、価値のない mint で quote された market で稼いだ手数料を、同じ数量だけ USDC の fee vault から claim できてしまう(tests/builder_fee_mint.rs)。最初の credit ではなく登録時に決めるのは、PlaceOrderWithBuilder に署名するのはトレーダーで builder ではないからだ。最初の credit で固定すると、誰でも他人の profile を価値のない mint に固定できてしまう。

演習 §13.1. あるフロントエンドが max_fee_share_bps = 3000 で登録する。3 つの取引が経由する: notional 1000、2500、700。PROTOCOL_FEE_BPS = 10(0.1%)で、3 取引すべての後の accumulated_fees 合計は?


§13.2 キャップ 2 つ、積み重ね

Builder code は 2 つの手数料シェア キャップが要る、1 つではなく、2 つの当事者が異なるインセンティブを持つから:

プロトコル キャップ (PROTOCOL_BUILDER_SHARE_CAP_BPS = 5000) — 任意の builder が任意のプロトコル手数料の留保できる最大 fraction。プログラムにハードコード。本書の値 5000 bps だと、プロトコルはすべてのプロトコル手数料の少なくとも 50% が builder 設定に関わらずプロトコルに残ることを保証する。

Builder 自己キャップ (BuilderProfile.max_fee_share_bps) — この特定の builder が留保する最大 fraction。登録時に自己宣言、ユーザに見える。

任意の取引での実効シェアは min(builder.max_fee_share_bps, PROTOCOL_BUILDER_SHARE_CAP_BPS)。programs/openhl-core/src/lib.rs:4044–4049 から:

share_bps = profile.max_fee_share_bps;
if share_bps > PROTOCOL_BUILDER_SHARE_CAP_BPS {
    // 防御的: 登録済み profile はキャップを超えるべきではないが、
    // builder 登録以降にキャップが下げられた可能性がある。
    share_bps = PROTOCOL_BUILDER_SHARE_CAP_BPS;
}

プロトコル キャップは登録時にクランプする(ユーザはキャップ以上を要求できない)が、取引時にも再クランプする — 登録と取引の間にキャップが下げられうるからだ。(本章は定数を出荷する。本物のプログラムは PROTOCOL_BUILDER_SHARE_CAP_BPS を config アカウント上に住むガバナンス調整可能値にするかもしれない。まさにそこで防御的再クランプが重要になる。)

2 キャップ構造はコアな安全性プロパティだ。扱う 3 つの失敗モード:

  1. 悪意ある builder。 何らかの形で登録時に max_fee_share_bps = 10000(手数料の 100%)を宣言する builder は即座に PROTOCOL_BUILDER_SHARE_CAP_BPS にクランプされる。プロトコルは常にフロアを保つ。
  2. ユーザのミス。 馴染みのない builder にルートするユーザでも、取引前に最大の手数料漏れを知っている — 両キャップをオンチェーン状態から読める。手数料の驚きはない。
  3. 侵害された builder。 Builder のウォレットが侵害され、攻撃者がシェアを膨らませようとしても、登録キャップ(登録後は不変)を超えられない — 再登録すれば新しい PDA が異なるアドレスに生まれ、既存のフローが壊れる。

本番 builder プログラムはしばしば第 3 のキャップを追加する — プロトコルが異なる market で異なる手数料分割を charge できる、market ごとや資産ごとのキャップ。本章は明快さのために省略するが、パターンは自然に拡張する。

演習 §13.2. Builder が max_fee_share_bps = 8000 で登録される。キャップ積み重ねを通れ: 本書の PROTOCOL_BUILDER_SHARE_CAP_BPS = 5000 で、実効シェアは? Builder 登録後にガバナンス投票がプロトコル キャップを 3000 に下げたら — 次の取引でその profile を通したら何が起きるか?


§13.3 PlaceOrderWithBuilder を歩く

programs/openhl-core/src/lib.rs の process_place_order_with_builder。ハンドラは PlaceOrderChecked の厳格な上位集合 — 同じ 8 つの先頭アカウント(user、book、oracle、market、mint、user_token、fee_vault_token、token_program)、末尾に追加の builder_profile アカウント 1 つ、同じ手数料計算、同じ place ロジックに、builder credit ステップを加える。

検証 + サニティ バンド: PlaceOrderChecked と同一。両ハンドラは渡された market に対して (book, oracle, mint) チェインをクロス検証し、オラクル staleness のガントレットを走らせ、価格サニティ バンドを適用する。

手数料計算 + builder credit:

let notional_val = (price as u128) * (size as u128);
let protocol_fee = (notional_val * (PROTOCOL_FEE_BPS as u128) / 10_000) as u64;

// ... builder profile を読み、share_bps をキャップでクランプ ...
if profile.fee_mint != *mint_ai.key.as_ref() {
    return Err(ProgramError::InvalidAccountData); // profile earns fees in another mint
}

let builder_share = ((protocol_fee as u128) * (share_bps as u128) / 10_000) as u64;
profile.accumulated_fees = profile.accumulated_fees.checked_add(builder_share)?;
new_volume = profile.total_volume.checked_add(size)?;
profile.total_volume = new_volume;

3 つの数値が落ちる:

  • notional_val = price × size — 取引の総価値、quote-unit-scaled 形式で。
  • protocol_fee = notional × 10 / 10_000 — notional の 0.1%、quote 単位。
  • builder_share = protocol_fee × share_bps / 10_000 — プロトコル手数料の fraction、quote 単位。

builder_share は checked_add で profile.accumulated_fees に追加(オーバーフローは静かにキャップする代わりに取引を拒否 — builder は走行合計を u64::MAX 以下に保つため定期的に claim できる)。total_volume は観測性のため取引サイズ分進む。

注文配置: §9.4 の PlaceOrderChecked と同一。空スロットを線形走査、注文を書く、カウンタをインクリメント。

手数料エスクロー: ハンドラは SPL Token Transfer の CPI(spl_token_transfer_user_signed)で終わり、ユーザの quote-token アカウントから per-(quote_mint) fee vault に全額の protocol_fee を移動する。(protocol_fee - builder_share) 残余は fee vault にプロトコルの取り分として残る。ClaimBuilderFees が後で各 builder への accumulated_fees を同じ vault から release する。最後に置くのは、Transfer 失敗(InsufficientFunds、凍結アカウント)が注文書き込みと builder credit の両方をアトミックに revert するようにするため — 第 11 章の担保エスクローと同じ順序重要性の論である。

PlaceOrderWithBuilder の CU コストは PlaceOrderChecked + 約 600 CU、builder profile 借用のため。手数料計算と手数料エスクロー Transfer は既に PlaceOrderChecked にある。builder なしパスに対する限界コストは builder credit だけだ。

演習 §13.3. price × size が大きすぎて notional × PROTOCOL_FEE_BPS が u128 をオーバーフローする PlaceOrderWithBuilder が呼ばれたら何が起きるか。失敗パスを辿れ。なぜこの計算に u64 ではなく u128 精度が正しいか?


§13.4 アトミシティ論

PlaceOrderWithBuilder は手数料分割を注文配置と同じ命令内で行う。「各取引の後、builder が別の RecordFee(trade_id, amount) 命令を呼ぶ」パターンはない。アトミシティが肝心な理由は 3 つ:

1. 決済の誠実性。 手数料分割が別トランザクションで起きるなら、ユーザは slot N で取引手数料を支払い、builder は slot N+1 でシェアを受け取れない可能性がある(アカウントがクローズされた、キャップが変わった、等)。アトミシティは: 取引が分割適用で commit するか、どちらも起きないかのいずれか、を意味する。「手数料は払ったが builder は credit を受け取らなかった」失敗モードはない。

2. CU 効率。 別の「record fee」命令はもう 1 トランザクションぶんの手数料、ネットワーク往復、CU オーバーヘッドを毎単一取引でかかる。1 日あたり数千取引 × builder ごとで、相当のコストになる。インライン蓄積は取引ごとに 1 回、claim は N 取引ごとに 1 回、総コストは償却される。

3. スケジューリング。 別の手数料記録命令は builder profile をすべての取引トランザクションで書き込み可能アカウントとして要求する、分割 == 0 でも。Sealevel はその後、同じ builder の profile 上のすべての取引を直列化する(第 5 章のアンチパターン)。分割が取引内なら、その builder にルートされた取引のみが profile に触れる — だから 2 builder のフローは同じ market を含んでも並列に処理できる。

蓄積(取引とアトミック、PlaceOrderWithBuilder 内)と claim(バッチ、別の ClaimBuilderFees 呼び出し)の分割も肝心だ。Claim は高価な操作: 実際のトークンを動かす必要があり(本番)、それは SPL Token CPI、それは signer セットアップとアカウント検証を意味する。それを取引ごとに行うのは無駄だ。Profile に蓄積させ builder に定期的に(時間ごと、日ごと、何でも)claim させることで、取引ごとのコストは最小限に保たれる。

この蓄積 - バッチ - claim パターンは Ethereum の ERC-20 配当分配と同一 — 同じ問題、同じ解、異なるランタイム。

演習 §13.4. 各取引が別の RecordFee 命令を発行し builder が処理しなければならない代替設計を草案せよ。負荷下(1 builder 経由で 10 取引/秒など)での BuilderProfile アカウントへの秒あたり書き込み数を数えよ。本書の設計と比較せよ。どちらが Sealevel をより積極的に直列化するか?


§13.5 RegisterBuilder、ClaimBuilderFees、CreateFeeVault

RegisterBuilder は第 3 章の標準 PDA 作成パターン、1 つのひねり付き: 要求された max_fee_share_bps は登録時にクランプされる:

if max_share > PROTOCOL_BUILDER_SHARE_CAP_BPS {
    msg!(
        "register_builder: requested {} bps clamped to protocol cap {} bps",
        max_share,
        PROTOCOL_BUILDER_SHARE_CAP_BPS
    );
    max_share = PROTOCOL_BUILDER_SHARE_CAP_BPS;
}

クランプは静か — 登録は成功する、シェアが下げられただけだ。クランプをログすると、builder はプログラム ログから実効キャップを検証できる。

ClaimBuilderFees は 2 ステップ — 借用スコープ内でアキュムレータをゼロ化、その後 fee vault からの PDA 署名 Transfer:

if profile.builder != *builder_ai.key.as_ref() {
    return Err(ProgramError::IllegalOwner);
}
if profile.fee_mint != *mint_ai.key.as_ref() {
    return Err(ProgramError::InvalidAccountData); // fees were earned in another mint
}
let claimed = profile.accumulated_fees;
profile.accumulated_fees = 0;
// ... 借用スコープ終了 ...

spl_token_transfer_fee_vault_signed(
    fee_vault_ai,
    builder_token_ai,
    fee_vault_auth_ai,
    mint_ai.key,
    fee_vault_auth_bump,
    token_ai,
    claimed,
)?;

認可(builder だけが自分の手数料を、fee_mint の vault からだけ claim できる)、アキュムレータをゼロ化、借用を解放、PDA 署名 Transfer。Transfer ヘルパは、第 11 章のポジションの払い出し(close と liquidate)に使う spl_token_transfer_vault_signed と構造的に同一 — 同じ invoke_signed パターン、同じ InvalidSeeds 保護 — 違いは seeds が per-(market) [VAULT_AUTH_SEED, market] ではなく per-(quote_mint) [FEE_VAULT_AUTH_SEED, quote_mint] という点だけ。順序が重要: Transfer の前に アキュムレータをゼロ化することで、(a) Transfer 失敗時の tx revert がアキュムレータを復元(アトミック)、(b) Transfer 成功時はまだ funded なアキュムレータに対するリプレイ攻撃を防ぐ。

本物の本番 claim は通常、部分引き出し(claim --amount N、常に全部ではなく)、蓄積子タイムアウト(>N 日アイドルな手数料はプロトコルに没収)、プロトコルが複数 quote 通貨をサポートするときの per-token claim もサポートする。どれも根本的な形を変えない。上に乗るポリシー判断だ。

CreateFeeVault は 1 回限りのブートストラップ。第 6 章の process_create_vault をそのままミラーする、2 つの違い: seeds が [b"vault", market, mint] / [b"vault_auth", market] ではなく [b"fee_vault", quote_mint] / [b"fee_vault_auth", quote_mint]、そして market ごとではなく quote 通貨ごとに 1 つだけ必要。ハンドラは標準的な 2-CPI ダンス — PDA 署名付き System create_account、その後 authority PDA を所有者に設定する SPL Token InitializeAccount3 — を行い、place-order バリアント実行前に一度走らせる。

演習 §13.5. process_claim_builder_fees を編集して、ペイロードに partial_amount: Option<u64> を受け入れるようにせよ。Some(n) なら min(n, accumulated_fees) を claim。None ならすべて claim。引き出せる総額が同じでも、部分引き出しパターンが builder に有用な理由は?

§13.5b 本番エスクロー設計ノート — 実装の背後にある 4 つの選択

第 11 章と第 12 章はポジション担保と vault deposit に実際の SPL Token エスクローを 1 つずつのマッチング ヘルパで追加した。本章のエスクローは代わりに 4 つの設計選択を必要とした。なぜなら builder 手数料は、それらの章が必要とした二者間トークン移動とは構造的に違うエスクロー問題だからだ。process_place_order_checked、process_place_order_with_builder、process_claim_builder_fees の実装は 4 つすべてに形作られる。これらを理解することが、コードを恣意的な選択ではなく強制的な手筋の系列として読ませる鍵だ。

1. 二者 vs 三者。 ポジション担保と vault deposit は二者間移動だ: ユーザ ↔ vault、片方が署名し、経済判断(いくら、誰の署名で)はすべて命令にエンコードされている。Builder 手数料は三者分割だ: ユーザは単一のプロトコル手数料を払い、そのうち一部がプロトコルへ、別の一部が builder へ行く。1 つのユーザ支払いを 2 つの受取人にアトミックに分割し、各々に正しい比率を credit するのは spl_token_transfer_user_signed と spl_token_transfer_vault_signed だけでは収まらない。実装は fee-vault トークン アカウント(quote mint ごとに 1 つ)を導入し、[b"fee_vault_auth", quote_mint] の PDA が所有する。すべての place-order バリアントが (a) ユーザのトークン アカウントから fee vault へ全額の protocol_fee を Transfer し、(b) (builder バリアントでは)builder のアキュムレータに builder_share を credit する。(protocol_fee - builder_share) 残余は fee vault にプロトコルの取り分として残る。

2. PlaceOrderChecked 非対称性。 手数料エスクローを PlaceOrderWithBuilder だけに追加するとねじれたインセンティブが生まれる: builder なしパスはトークン建て手数料を取らないので、builder 経由の取引は同じ取引より実質コストが高くなる。本章は すべての place-order バリアントにプロトコル手数料エスクローを入れて解決する — だから process_place_order_checked も PlaceOrderWithBuilder と同じ (market, mint, user_token, fee_vault_token, token_program) プレフィックスに育った。両バリアントが同じ手数料をエスクローし、builder バリアントは追加で builder share を credit する。本番デプロイがこの選択を初日にやるのは、非対称設計が一方向の移行罠だからだ — ユーザが特定のアカウント形に依存するトランザクションに署名し始めた後で、静かにスロットを追加することはできない。

3. 複数 quote 対応。 単一 quote 通貨(本章のケース)なら fee vault はクラスタごとに 1 アカウント、builder ごとのアキュムレータは単一 u64。複数 quote 通貨では、(quote_mint) ごとの fee vault かつ (builder, quote_mint) ごとのアキュムレータが必要 — BuilderProfile はマップ フィールドを持つ(Pod 適合しない)か、quote ごとに別 profile PDA に分かれる。§13.1 で単一 quote 制約を意図的だと述べた。ここがアーキテクチャ的に報酬を払う場所だ。注意: 選んだ PDA seeds([b"fee_vault"] ではなく [b"fee_vault", quote_mint])は既に per-mint vault をサポートしている。複数 quote 化で再構造化が必要なのは (builder, quote_mint) アキュムレータ側だけだ。

4. Claim 側の authority。 Fee vault が揃えば、ClaimBuilderFees は fee vault authority PDA で署名された fee_vault → builder_token の PDA 署名 Transfer になる — §12.4 の VaultWithdraw と構造的に同一、同じ invoke_signed パターン、同じ InvalidSeeds 保護、違う seeds。これは常に、先行章がすでにやった作業の「機械的拡張」と呼べる部分だった。新しい spl_token_transfer_fee_vault_signed ヘルパとして per-(market) のいとこと隣接して着地する。

本章が教える仕組み — 取引とアトミックな accrual、別バッチ操作としての claim、二段キャップ安全性 — は 4 つの設計判断すべてを通じて変わらない。選択 1 と 2 が Transfer がどこに落ちるかを決め、選択 4 が claim がどう署名するかを決める。選択 3 は seeds を mint-scoped に保ったことで無料で得られる。

演習 §13.5b(設計). scripts/builder/src/main.rs の --place-with-builder、--claim、および第 12 章の元の --withdraw(scripts/vault/src/main.rs)の accounts: vec![...] 宣言を比較せよ。3 つの構造的類似と 3 つの構造的相違を識別せよ。各相違を上の 4 つの設計判断のどれかに紐付けよ。ヒント: seed-derivation パターンは同一、authority 側は同一、非対称性はアカウント数、signer の役割、PDA-derivation seeds にある。


§13.6 まとめと自己検証

まとめ図

Builder ライフサイクル:

  0) クラスタ ブートストラップ(quote mint ごとに 1 回)
     CreateFeeVault(quote_mint)
     ──► [b"fee_vault", quote_mint] の fee_vault トークン アカウント
         [b"fee_vault_auth", quote_mint] PDA が所有


  1) Builder 登録
     RegisterBuilder(max_fee_share_bps=2000)
     accounts: [payer/builder(S,W), builder_profile(W), system_program(R),
                mint(R)]
     ──► BuilderProfile{ builder, max_share=2000, fees=0, vol=0,
                         fee_mint=mint }


  2) ユーザが builder 経由で取引
     PlaceOrderWithBuilder(side, price, size)
     accounts: [user(S,W), book(W), oracle(R), market(R), mint(R),
                user_token(W), fee_vault(W), token_program(R),
                builder_profile(W)]

       notional       = price × size
       protocol_fee   = notional × 10 / 10000    (PROTOCOL_FEE_BPS)
       share_bps      = min(profile.max, 5000)    (PROTOCOL キャップ)
       builder_share  = protocol_fee × share_bps / 10000

     アトミック:
       book に注文配置                            ─┐
       profile.accumulated_fees += builder_share   ├─ 同じ tx、同じ slot
       profile.total_volume     += size            │
       SPL Token Transfer:                         │
         user_token → fee_vault   protocol_fee     ─┘


  3) Builder が定期的に claim
     ClaimBuilderFees(empty)
     accounts: [builder(S), profile(W), mint(R), fee_vault(W),
                fee_vault_auth(R), builder_token(W), token_program(R)]
     ──► profile.accumulated_fees = 0
     ──► invoke_signed SPL Token Transfer:
           fee_vault → builder_token  (claim 額)
           signer seeds: [b"fee_vault_auth", quote_mint, &[bump]]


2 キャップ安全性:

  effective_share = min( builder.max_fee_share_bps
                       , PROTOCOL_BUILDER_SHARE_CAP_BPS
                       )

  ↑ builder が最大自己キャップにコミット(ユーザに見える)
  ↑ プロトコルがキャップのキャップにコミット(ハードコード / ガバナンス)

  プロトコル取り分のフロア = (10000 - 5000) × PROTOCOL_FEE_BPS / 10000
                          = notional の 5 bps、常に留保

自分で検証する 3 項目

前提: quote mint の fee vault をブートストラップ(builder --create-fee-vault --mint <quote_mint>)し、トレーダが funded な quote-token アカウントを持つことを確認すること。さもなくば手数料 Transfer が revert する。

  1. キャップが正しく積み重なる。 --max-share-bps 9999 --mint <quote_mint> で builder を登録する。ハンドラはクランプをログし、ダンプは max_fee_share_bps = 5000(本書の PROTOCOL_BUILDER_SHARE_CAP_BPS)を示すはずだ。次に取引をその builder 経由でルートする — builder のシェアはちょうどプロトコル手数料の 50% のはずだ。
  2. 失敗下でアトミシティが保たれる。 シミュレートされた失敗を使う: stale なオラクルで PlaceOrderWithBuilder を試す(注文配置が失敗する)。シミュレーションは oracle stale で失敗し、かつ失敗 sim 後、builder profile の accumulated_fees は不変、fee vault の残高は不変、トレーダの quote-token アカウントも不変のはずだ(トランザクション全体が revert するので)。取引が起きない限り分割は起きない。
  3. 取引ごとに volume が蓄積する。 同じ builder 経由で 5 つの注文をサイズ 10、20、30、40、50 で配置する。total_volume はちょうど 150 (10+20+30+40+50)のはずだ。accumulated_fees が (notional_total × PROTOCOL_FEE_BPS × share_bps / 10000 / 10000) に加算されないなら、数学に off-by-one がある — それを追いかけよ。その後 --claim --mint <fee_mint> --builder-token-account <pubkey> を走らせる(ダンプが fee_mint を表示する)。Builder の quote-token アカウント残高は accumulated_fees ぶんちょうど増え、ダンプは accumulated_fees = 0 を示すはずだ。

アカウントだけでなく、お金を道具に確かめさせる

第 3・6・12 章は、solinv にアカウントを差し替えさせて終わった。ここでの問いは違う。手数料の vault は、builder に払うべき額をいつも持っているか。第 6 章 §6.6 はこれを持ち越した。手数料の vault は全市場が共有する唯一のアカウントで、組には PDA ではない 2 つ目の市場がありうるからだ。

fuzz/fees/ がそのハーネスである。builder B と B2 は quote の mint Q で登録し、利用者 U はどちらかを通して取引する。市場は 3 つあり、それぞれに板と新しいオラクルがある。PDA にある (base, Q)、第 2 章の Initialize で PDA ではないアドレスに作った 2 つ目の (base, Q)、誰でも作れる mint J の (base, J) だ。いつもの signer-skip と account-swap に加えて、仕様は 2 つの規則を宣言する:

  • 関係(solinv の Relation。unchecked_math が確かめる): PlaceOrderWithBuilder と ClaimBuilderFees の両方で、B と B2 の profile の accumulated_fees の合計が Q の手数料の vault の残高以下。(関係が読めるよう、B2 の profile を各命令のアカウントの末尾に足してある。ハンドラはその余分なアカウントを読まない。)命令の前に成り立っていて、後で崩れたときだけ報告する。
  • ガード(precondition_skip が確かめる): PlaceOrderWithBuilder が成功してよいのは profile.fee_mint == market.quote_mint のときだけ。solinv が比べるフィールドは 16 バイトまでなので、32 バイトの mint にはガードが 2 つ要る。

第 3 章 §3.7 のとおり準備してから:

cargo build-sbf --manifest-path programs/openhl-core/Cargo.toml
cd fuzz/fees
cargo build --release --features invariant_ch13
cargo test --release --features invariant_ch13
timeout -s INT 60 crucible run openhl_fees_fuzz invariant_ch13 \
    --binary-in target/release/invariant_test --timeout 45

今のプログラムでは、3 つの市場のどれでも [FUZZ_FINDING] の行は出ない。これが、この章についての §6.6 の答えになる。accumulated_fees への加算はどれも、同じトランザクションが同じ mint で手数料の vault に移す protocol_fee の半分以下なので、2 つ目の市場でも(どの市場でも)、利用者が払った以上を builder に借りることはできない。テスト credit_never_exceeds_payment が両方の Q の市場でこれを確かめる。

演習 §13.6. 下の検査を 1 つずつ消し(最後の 2 行はその変更を加え)、作り直して走らせよ。先に予想すること。私たちが各 45 秒で測った結果を示す。「いいえ」の行の最後の列は、差し替えた呼び出しを代わりに拒否したもので、ログから読んだ(probe テストが出力する):

変更捕まったかどう捕まったか、または代わりに拒否したもの
Place: profile.fee_mint == mintはいprecondition-skip: J の市場でガードが偽のまま注文が通った。junk_mint_order_does_not_credit_a_q_builder が落ちる
Place: market.quote_mint == mintいいえfee_mint の検査: Q の市場で J で払っても、指定する builder は Q で払われる B のまま
Place: verify_fee_vault_token_accountいいえSPL Token: Account not associated with this Mint
Place: user_ai.is_signerいいえランタイム: 権限のない署名者
Place: 板の PDAはいaccount-swap: 注文が別の市場の板に入った
Place: オラクルの PDAはいaccount-swap: 価格の帯が別の市場のオラクルから来た
Claim: profile.builder == builderはいaccount-swap: B が B2 の手数料を受け取った
Claim: builder_ai.is_signerはいsigner-skip: 誰でも B の手数料を、自分の指定する口座に受け取れた
Claim: profile.fee_mint == mintはい(組で)account-swap check_sets: B の Q の手数料が J の vault から B の J の口座へ払われた(4 つの slot を一緒に)
Claim: verify_fee_vault_token_accountいいえSPL Token: owner does not match(Q の vault の authority は J の vault に署名できない)
Claim: accumulated_fees = 0 を消すはい関係、最初の claim で: 前は vault の 12,266 に対して借り 12,262、後は 6 に対して 12,262。claim_pays_once が落ちる
Place: builder の取り分を protocol_fee でなく notional から計算はい関係: 最初の注文の前は借り 0 対 vault 0、後は 10 に対して 5,000

3 つのことを見直す価値がある。最後の 2 行は、検査の抜けではまったくない。どのアカウントも正しく、どの検査も通るのに、プログラムは持っている以上を約束する。それが見えるのは数についての規則だけで、それはこの章の原子性の議論(§13.4)が扱っている規則でもある。もう 1 つ、これは私たちのハーネスの罠だ。最初の版では B2 が setup() の中で手数料を得ていた。取り分を notional から計算すると、その準備の注文の時点で関係がもう崩れており、solinv は命令の前から偽の関係を検出ではなく宣言の問題として報告するので、この行は「いいえ」になっていた。今は fuzzer 自身の注文だけが builder に加算する。最後に、claim 側の fee_mint の行には、4 つのアカウントを一緒に差し替える必要があった。私たちの最初の組は 3 つを差し替え、送り先を B の Q の口座のままにしていたので、SPL Token が mint の不一致で拒否した。それは消した検査について何も語っていなかった。攻撃者は J の送り先を指定する。第 12 章と同じく、拒否は、どの検査が拒否したかが分かって初めて証拠になる。

1 つの差し替えはわざと外してある。PlaceOrderWithBuilder の slot 8 を別の builder の profile にすると通る。U が B ではなく B2 に加算する。それが設計だ。どのフロントエンドに加算するかは利用者が選び、どちらでも手数料は払う。


第 14 章への導線

本番デプロイが必要とするすべてのプリミティブを扱う perp DEX プログラムを持つようになった: アカウント、プログラム、PDA、CPI、コンピュート、並列性、vault、板、マッチャ、オラクル リーダ、ファンディング、ポジション、清算、プール取引 vault、builder code。まだ持っていないのは、それを走らせ続けるオフチェーン配管だ。

第 14 章 — Cranks、Keepers、オフチェーン グルー — が Phase B とトラックを閉じる。本プログラムが暗黙に要求するすべての keeper を歩く: ファンディング レート keeper(第 10 章の hook)、清算 bot(第 11 章の無許可 Liquidate)、vault NAV reporter(第 12 章の UpdateNAV cadence)、builder claim cron(第 13 章の蓄積子)、オラクル publisher(Pyth を使わず自前で走らせるなら第 9 章の SetOraclePrice)、マッチング エンジン cranker(非同期マッチングを組み立てていたら)、フロントエンドにフィードするオフチェーン インデクサ。章は新しいオンチェーン コードがゼロ — その内容はオフチェーン プロセスの設計パターン、手数料経済、冗長性とフェイルオーバー、「Solana DEX」がオンチェーン プログラム半分とコーディネートされたオフチェーン インフラ半分であるというアーキテクチャ上の現実だ。