第9章 テスト#
第7章までで、レジストリと Transfer Hook による移転制限の実装が揃いました。本章では、それが本当に意図どおり動くことを確認する工程を扱います。
金融資産を扱うプログラムでは、「動くこと」の確認だけでは不十分です。動いてはいけないケースで確実に止まること、そしてその失敗が「防御が働いた結果」であって偶然や設定ミスによるものでないことまでを確認する必要があります。本章はこの観点でテストを組み立てます。
本章に掲載するテストコードは、すべてリポジトリの検証済みソースからの転記です。掲載する実行結果も、執筆時に実際に実行した出力です。
9.1 テスト戦略の全体像#
9.1.1 5つの層と、それぞれの限界#
テストは1種類では足りません。速いが本番と乖離する層と、本番に近いが遅い層を組み合わせます。本書では次の5層で構成します。
| 層 | 実行環境 | 速度 | 主に検証できること | 検証できないこと | 本書での実施 |
|---|---|---|---|---|---|
| ① 単体 | LiteSVM(Rust) | ミリ秒級 | プログラム単体のロジック、権限チェック、状態遷移 | 実際の手数料、ネットワーク遅延、他プログラムとの実接続 | 実施(第9.3節) |
| ② 統合 | LiteSVM + 実 Token-2022 | ミリ秒〜秒級 | Token-2022 から Hook への CPI 経路、拡張の実挙動 | クラスタ固有の制約、実際のレント収支 | 実施(第9.4・9.5節) |
| ③ フォーク | Surfpool(mainnet フォーク) | 秒級 | mainnet の実アカウント状態を前提とした挙動 | simnet 側の未実装機能に依存する処理 | 一部実施(第9.1.3項) |
| ④ devnet | 実クラスタ(devnet) | 分級 | 実クラスタでのトランザクション成立、レント、拡張の実データ | mainnet 固有の混雑・手数料水準 | 実施(第9.6節) |
| ⑤ 限定 mainnet | 実クラスタ(mainnet) | 分級 | 本番同等の最終確認 | — | 本書のスコープ外 |
下の層ほど速く、上の層ほど本番に近くなります。開発中は①②を高頻度で回し、リリース判断の前に④を通すという使い分けが基本です。
9.1.2 リファレンス実装のテスト構成#
本書のリファレンス実装は、次の3ファイルで合計26個のテストを持ちます。
| ファイル | テスト数 | 対象 |
|---|---|---|
programs/registry/tests/test_registry.rs |
3 | レジストリ単体(正常系・権限チェック・状態遷移) |
programs/transfer_hook/tests/test_transfer_hook.rs |
15 | Token-2022 転送を含む統合(全組合せ・凍結/thaw・強制回収) |
programs/transfer_hook/tests/test_attacks.rs |
6 | 異常系・攻撃シナリオ |
これに各プログラムの test_id(宣言したプログラムIDとビルド成果物の一致確認)が2件加わり、合計26件です。
9.1.3 Surfpool 層で判明した制約(実例)#
第6章のミント作成スクリプトは、当初 Surfpool(mainnet フォーク環境)で検証しました。4つの拡張の初期化と InitializeMint までは成功しましたが、可変長の Token Metadata 本体 TLV を書き込む際の realloc で処理が停止しました。
これはスクリプト側の誤りではなく、フォーク環境(simnet)側の制約でした。後述の devnet 実機検証(9.6節)では同じスクリプトが正常に完走しています。
この経験から得られる教訓は次のとおりです。フォーク環境での失敗を、直ちに実装の誤りと判断しないこと。 層ごとに「何が再現され、何が再現されないか」が異なるため、失敗した層より上の層で切り分ける必要があります。
9.2 要件とのトレーサビリティ#
9.2.1 テストは要件から引く#
テストケースを「思いついた順」に書くと、抜けが発生しても気づけません。本書では第3章の要件に ID を付け、各テストがどの要件を検証しているかを対応表で管理します。これにより「どの要件が未検証か」が機械的に分かります。
第3章の要件に付与した ID は次のとおりです。
| 要件ID | 出典(第3章) | 要件内容 |
|---|---|---|
| R-TRANSFER-01 | 3.1「移転は許可済み投資家間のみ」 | 送信者・受信者の双方が Approved のときのみ移転が成立する |
| R-TRANSFER-02 | 3.1 同上 | 送信者または受信者が未許可(Suspended/未登録)なら移転は失敗する |
| R-FREEZE-01 | 3.1「発行体による凍結」/ 6.5 | 新規トークンアカウントは既定 Frozen。thaw 前は受領・移転できない |
| R-FREEZE-02 | 3.1「凍結」 | 発行体は既存の口座を再度 freeze でき、freeze 中は移転できない |
| R-THAW-01 | 6.5「KYC完了後 thaw→配布」 | KYC完了(=registry登録)後に thaw して初めて配布・移転が可能になる |
| R-FORCE-01 | 3.1「発行体による強制回収」/ 7.5 | Permanent Delegate 権限者は投資家の同意なしに強制移転・回収できる |
| R-FORCE-02 | 7.5 | Permanent Delegate 以外は強制移転できない(権限の限定) |
| R-AUTH-01 | 3.1.5「管理者権限」/ 7.2 | registry の状態変更は管理者(has_one = admin)のみが行える |
| R-HOOK-01 | 7.1「hook が双方の許可を検証」 | Hook は転送時に送信者・受信者双方の InvestorStatus を検証する |
| R-SEC-01 | 10.1「account confusion」/ 7.3 | 偽の InvestorStatus(所有者・シード不一致)は拒否される |
| R-SEC-02 | 10.1「missing signer / 権限昇格」 | 転送コンテキスト外からの Hook 直接呼び出しは拒否される |
| R-SEC-03 | 9.5「hookバイパスの試行」 | 追加アカウントを差し替え・省略して Hook 検証を迂回できない |
9.2.2 要件 → テスト対応表#
| 要件ID | カバーするテスト |
|---|---|
| R-TRANSFER-01 | T-01 |
| R-TRANSFER-02 | T-02, T-03, T-04, T-05, T-06 |
| R-FREEZE-01 | T-10, T-40 |
| R-FREEZE-02 | T-12 |
| R-THAW-01 | T-11, T-13 |
| R-FORCE-01 | T-20, T-22, T-23 |
| R-FORCE-02 | T-21 |
| R-AUTH-01 | T-30 |
| R-HOOK-01 | T-01, T-22 |
| R-SEC-01 | T-31, T-32 |
| R-SEC-02 | T-33 |
| R-SEC-03 | T-34, T-35 |
全12要件にテストが対応しており、未カバーの要件はありません。 テストケース T-* の詳細は以降の各節で示します。
9.3 LiteSVM による単体テスト#
9.3.1 LiteSVM の位置づけとテストの基本形#
LiteSVM は、バリデータを起動せずに Solana ランタイムをプロセス内で動かすテストライブラリです。バリデータの起動待ちがないため、テスト1件あたりミリ秒級で実行できます。本書では anchor init(Anchor 1.1.2)の v1 テンプレートが既定で採用する 0.10.0 を使用します(第1章バージョン表)。
テストの基本形は「①ビルド済み .so を読み込む → ②命令を組み立てる → ③送信して結果を検証する」です。
// programs/registry/tests/test_registry.rs
// テスト用にプログラムをLiteSVMへ読み込み、初期化するヘルパ。
fn setup() -> (LiteSVM, Pubkey) {
let program_id = registry::id();
let mut svm = LiteSVM::new();
// anchor build が出力した .so を読み込む(anchor init のテンプレと同じ参照方法)。
let bytes = include_bytes!(concat!(
env!("CARGO_TARGET_TMPDIR"),
"/../deploy/registry.so"
));
svm.add_program(program_id, bytes).unwrap();
(svm, program_id)
}
ここで重要なのは、テストが include_bytes! でビルド成果物の .so を読み込む点です。つまり anchor build を先に実行しないとテストは動きません。 この順序は 9.7 節の CI でも同じ制約になります。
9.3.2 異常系は「失敗理由」まで確認する#
異常系テストで最も危険なのは、意図した防御とは別の理由で失敗しているのに、テストが通ってしまうことです(偽陽性)。たとえば署名不足やアカウント不足で失敗していても、is_err() だけを見ていれば「防御が働いた」と誤読できます。
これを避けるため、失敗の確認に加えて状態が変化していないことまで検証します。
// programs/registry/tests/test_registry.rs
// 異常系:管理者でない署名者による投資家登録は失敗する(権限チェックの検証)。
#[test]
fn test_register_by_non_admin_fails() {
let (mut svm, program_id) = setup();
let admin = Keypair::new();
let attacker = Keypair::new();
let config = config_pda(&program_id);
svm.airdrop(&admin.pubkey(), 10_000_000_000).unwrap();
svm.airdrop(&attacker.pubkey(), 10_000_000_000).unwrap();
// 正規の管理者で初期化する。
send(
&mut svm,
ix_initialize(program_id, admin.pubkey(), config),
&admin,
&[&admin],
)
.unwrap();
// attacker(管理者でない)が投資家登録を試みる。
// Config の has_one = admin 制約に違反し、トランザクションは失敗するはず。
let investor = Keypair::new().pubkey();
let inv_pda = investor_pda(&program_id, &investor);
let res = send(
&mut svm,
ix_register(
program_id,
attacker.pubkey(),
config,
inv_pda,
investor,
InvestorState::Approved,
),
&attacker,
&[&attacker],
);
assert!(res.is_err(), "管理者でない呼び出しは失敗しなければならない");
// 失敗したため、InvestorStatus アカウントは作成されていないはず。
assert!(svm.get_account(&inv_pda).is_none_or(|a| a.data.is_empty()));
}
最後の1行が重要です。attacker に十分な残高を airdrop している点にも注目してください。残高不足という別要因で失敗する可能性を排除したうえで、権限チェックだけが失敗要因になるようにしています。
9.4 Token-2022 転送を含む統合テスト#
9.4.1 統合テストの構成#
統合テストでは、LiteSVM に同梱される実際の Token-2022 プログラム(spl_token_2022 10.0.0)を使います。モックではないため、Token-2022 が Hook を CPI で呼び出す経路がそのまま実行されます。
テストごとに環境を組み立てると重複が大きいため、共通の fixture を用意します。
// programs/transfer_hook/tests/test_transfer_hook.rs
// 両投資家を Approved で登録し、送金元に残高を用意した状態まで作る。
fn setup_fixture() -> Fixture {
let mut svm = setup();
let payer = Keypair::new();
svm.airdrop(&payer.pubkey(), 100 * LAMPORTS_PER_SOL).unwrap();
let mint = Keypair::new();
create_hook_mint(&mut svm, &payer, &mint);
// registry を初期化し、送信者・受信者を Approved で登録する。
let sender = Keypair::new();
let receiver = Keypair::new();
send(&mut svm, &[ix_reg_init(payer.pubkey())], &payer, &[&payer]).unwrap();
register(&mut svm, &payer, &sender.pubkey(), registry::state::InvestorState::Approved);
register(&mut svm, &payer, &receiver.pubkey(), registry::state::InvestorState::Approved);
// 送信者・受信者のトークン口座を作成(thaw 済み)。
let source_ata = create_token_account(&mut svm, &payer, &mint.pubkey(), &sender.pubkey());
let dest_ata = create_token_account(&mut svm, &payer, &mint.pubkey(), &receiver.pubkey());
// 送信者口座に発行(mint authority = payer)。
mint_to(&mut svm, &payer, &mint.pubkey(), &source_ata, 1_000);
Fixture { svm, payer, mint, sender, receiver, source_ata, dest_ata }
}
このテストでは、payer が mint authority / freeze authority / permanent delegate / registry admin を兼ねています。これはテストを簡略化するための構成です。実運用ではこれらの権限を分離し、マルチシグ化することを検討してください(第3章 3.1.5、第10章)。
9.4.2 移転可否の全組合せ(T-01〜T-06)#
移転可否は送信者と受信者の状態の組合せで決まります。「成功するケース1つ」だけを確認するのでは不十分で、失敗すべき組合せを網羅します。
| # | 要件ID | 送信者状態 | 受信者状態 | 期待結果 | 対応テスト関数 |
|---|---|---|---|---|---|
| T-01 | R-TRANSFER-01 / R-HOOK-01 | Approved | Approved | 成功 | test_transfer_between_approved_succeeds |
| T-02 | R-TRANSFER-02 | Approved | Suspended | 失敗 | test_transfer_to_suspended_receiver_fails |
| T-03 | R-TRANSFER-02 | Suspended | Approved | 失敗 | test_transfer_from_suspended_sender_fails |
| T-04 | R-TRANSFER-02 | Suspended | Suspended | 失敗 | test_transfer_both_suspended_fails |
| T-05 | R-TRANSFER-02 | 未登録 | Approved | 失敗 | test_transfer_from_unregistered_sender_fails |
| T-06 | R-TRANSFER-02 | Approved | 未登録 | 失敗 | test_transfer_to_unregistered_receiver_fails |
「未登録」を Suspended と別扱いにしている点が重要です。Suspended は「登録済みだが停止中」、未登録は「InvestorStatus PDA が存在しない」状態で、Hook 内で失敗する箇所が異なります。未登録の場合は、PDA の所有者が registry ではない(存在しない)ため、verify_investor_status_pda の段階で弾かれます。
正常系(T-01)は次のとおりです。
// programs/transfer_hook/tests/test_transfer_hook.rs
// 正常系:許可済み(Approved)同士の転送は成功する。
#[test]
fn test_transfer_between_approved_succeeds() {
let mut f = setup_fixture();
let ix = transfer_checked_with_hook(
&f.mint.pubkey(),
&f.source_ata,
&f.dest_ata,
&f.sender.pubkey(),
&f.receiver.pubkey(),
100,
);
send(&mut f.svm, &[ix], &f.payer, &[&f.payer, &f.sender])
.expect("Approved 同士の転送は成功するはず");
assert_eq!(token_amount(&f.svm, &f.dest_ata), 100);
assert_eq!(token_amount(&f.svm, &f.source_ata), 900);
}
異常系(T-04)では、失敗の確認に加えて双方の残高が変化していないことを検証します。
// programs/transfer_hook/tests/test_transfer_hook.rs
// T-04:送信者・受信者の双方が Suspended なら失敗する。
#[test]
fn test_transfer_both_suspended_fails() {
let mut f = setup_fixture();
let (sender_pk, receiver_pk) = (f.sender.pubkey(), f.receiver.pubkey());
set_status(&mut f.svm, &f.payer, &sender_pk, registry::state::InvestorState::Suspended);
set_status(&mut f.svm, &f.payer, &receiver_pk, registry::state::InvestorState::Suspended);
let ix = standard_transfer(&f, 100);
let res = send(&mut f.svm, &[ix], &f.payer, &[&f.payer, &f.sender]);
assert!(res.is_err(), "双方 Suspended の転送は失敗しなければならない");
assert_eq!(token_amount(&f.svm, &f.source_ata), 1_000);
assert_eq!(token_amount(&f.svm, &f.dest_ata), 0);
}
9.4.3 凍結 / 凍結解除の運用フロー(T-10〜T-13)#
第6章で設定した Default Account State(Frozen)により、新規に作られたトークンアカウントは既定で凍結されています。ここでテストすべきは個々の命令ではなく、運用フロー全体が成立することです。
| # | 要件ID | シナリオ | 期待結果 | 対応テスト関数 |
|---|---|---|---|---|
| T-10 | R-FREEZE-01 | Frozen の新規口座に thaw せず受領しようとする | 失敗 | test_default_frozen_blocks_before_thaw |
| T-11 | R-THAW-01 | KYC完了 → thaw → mint_to → 移転まで一連が成立 | 成功 | test_thaw_flow_enables_transfer |
| T-12 | R-FREEZE-02 | 一度 thaw した口座を発行体が再 freeze → その口座からの移転 | 失敗 | test_refreeze_blocks_transfer |
| T-13 | R-THAW-01 | 受信者口座が Frozen のまま(未 thaw)への移転 | 失敗 | test_transfer_to_frozen_account_fails |
T-11 は、KYC 完了から移転までの一連の流れをそのままテストにしたものです。番号付きコメントが運用手順に対応しています。
// programs/transfer_hook/tests/test_transfer_hook.rs
// T-11:KYC完了(registry登録)→ thaw → mint_to → 移転、の一連が成立する。
#[test]
fn test_thaw_flow_enables_transfer() {
let mut svm = setup();
let payer = Keypair::new();
svm.airdrop(&payer.pubkey(), 100 * LAMPORTS_PER_SOL).unwrap();
let mint = Keypair::new();
create_hook_mint(&mut svm, &payer, &mint);
send(&mut svm, &[ix_reg_init(payer.pubkey())], &payer, &[&payer]).unwrap();
let sender = Keypair::new();
let receiver = Keypair::new();
// ① KYC完了 = registry に Approved 登録。
register(&mut svm, &payer, &sender.pubkey(), registry::state::InvestorState::Approved);
register(&mut svm, &payer, &receiver.pubkey(), registry::state::InvestorState::Approved);
// ② 口座作成(この時点では Frozen)。
let source_ata = create_token_account_frozen(&mut svm, &payer, &mint.pubkey(), &sender.pubkey());
let dest_ata = create_token_account_frozen(&mut svm, &payer, &mint.pubkey(), &receiver.pubkey());
// ③ thaw(配布可能化)。
thaw_account(&mut svm, &payer, &mint.pubkey(), &source_ata);
thaw_account(&mut svm, &payer, &mint.pubkey(), &dest_ata);
// ④ 発行 → ⑤ 移転。
mint_to(&mut svm, &payer, &mint.pubkey(), &source_ata, 1_000);
let ix = transfer_checked_with_hook(
&mint.pubkey(), &source_ata, &dest_ata,
&sender.pubkey(), &receiver.pubkey(), 100,
);
send(&mut svm, &[ix], &payer, &[&payer, &sender])
.expect("thaw 済み・Approved 同士の移転は成功するはず");
assert_eq!(token_amount(&svm, &dest_ata), 100);
}
ここで凍結と Hook は独立した2つの制御である点を確認しておきます。凍結は Token-2022 が口座単位で行う制御で、Hook の許可判定より前に効きます。したがって「registry で Approved だが口座が Frozen」なら移転は成立しません(T-13)。逆に「thaw 済みだが Suspended」でも成立しません(T-02、T-03)。両方を満たす必要があります。
9.4.4 強制回収(T-20〜T-23)#
第7章 7.5 で実装した Permanent Delegate による強制回収は、コンプライアンス上の要請(裁判所命令や規制当局の指示への対応)から必要になる機能です。同時に最も強い権限でもあるため、テストは「できること」と「できないこと」の両方を厳密に押さえます。
| # | 要件ID | シナリオ | 期待結果 | 対応テスト関数 |
|---|---|---|---|---|
| T-20 | R-FORCE-01 | Permanent Delegate が Suspended 投資家の残高を強制回収。回収先=発行体トレジャリ(Approved 登録) | 成功 | test_permanent_delegate_force_transfer_succeeds |
| T-21 | R-FORCE-02 | Permanent Delegate 以外(第三者)が同じ強制移転を試みる | 失敗 | test_non_delegate_force_transfer_fails |
| T-22 | R-FORCE-01 / R-HOOK-01 | 回収先が Approved である必要がある(Hook は強制移転でも走る) | 成功/失敗 | test_permanent_delegate_force_transfer_succeeds / test_force_transfer_to_unregistered_treasury_fails |
| T-23 | R-FORCE-01 / R-FREEZE-01 | 凍結中口座からの強制回収→失敗、thaw 後→成功 | 失敗→成功 | test_force_transfer_from_frozen_then_thawed |
T-20 では、送信者が Suspended のままで回収が成立します。これは第7章 7.5 で述べた「Permanent Delegate 経路に限り、送信者側の Approved 検証を免除する」という設計によるものです。強制回収の対象は通常 Suspended にした投資家であるため、送信者に Approved を要求すると機能しなくなるためです。
// programs/transfer_hook/tests/test_transfer_hook.rs
// T-20:Permanent Delegate は Suspended 投資家の残高をトレジャリへ強制回収できる。
// T-22(成功側):回収先トレジャリは Approved 登録されているため Hook を通過する。
#[test]
fn test_permanent_delegate_force_transfer_succeeds() {
let (mut f, treasury_owner, treasury_ata) = setup_with_treasury();
// 対象投資家(sender)を Suspended にする(強制回収の典型シナリオ)。
let sender_pk = f.sender.pubkey();
// 対象保有者(sender)を Suspended にする(強制回収の典型シナリオ)。
// 再 Approve はしない(承認済み設計:delegate 経路は送信者側 Approved 検証を免除)。
set_status(&mut f.svm, &f.payer, &sender_pk, registry::state::InvestorState::Suspended);
// permanent delegate(=payer)署名で、Suspended の sender から強制回収する。
// 回収先=発行体トレジャリ(Approved 登録済み)。受信者側の Approved 検証は免除されない。
let ix = force_transfer_checked_with_hook(
&f.mint.pubkey(),
&f.source_ata,
&treasury_ata,
&f.payer.pubkey(), // authority = permanent delegate(ミント拡張値と一致)
&f.sender.pubkey(), // source owner(Suspended)
&treasury_owner.pubkey(), // destination owner = トレジャリ(Approved)
1_000,
);
let res = send(&mut f.svm, &[ix], &f.payer, &[&f.payer]);
// Permanent Delegate 経路のため、送信者(Suspended)の Approved 検証は免除される。
// Hook 自体は走り、受信者トレジャリの Approved は検証される(例外経路ではない)。
assert!(
res.is_ok(),
"Permanent Delegate による Suspended 保有者からの強制回収は(再 Approve なしで)成功するはず: {res:?}"
);
assert_eq!(token_amount(&f.svm, &treasury_ata), 1_000);
assert_eq!(token_amount(&f.svm, &f.source_ata), 0);
}
免除の範囲を証明するテスト#
ここが本節で最も注意を要する点です。「送信者側の検証を免除する」という例外を入れた以上、その免除が delegate 経路だけに限定されていることを証明しなければなりません。免除が漏れていれば、Suspended の投資家が自分で送金できてしまい、移転制限そのものが崩れます。
そこで、Suspended の保有者本人が署名した通常転送は、宛先が Approved であっても失敗することを別テストで確認します。
// programs/transfer_hook/tests/test_transfer_hook.rs
// T-20 補助(a):送信者側 Approved 検証の免除が「delegate 経路に限定される」ことの証明。
// Suspended 保有者「本人」が署名する通常転送は、回収先が Approved トレジャリであっても失敗する。
// (免除は authority がミントの permanent delegate と一致する場合のみ働く)
#[test]
fn test_suspended_owner_self_transfer_still_fails() {
let (mut f, treasury_owner, treasury_ata) = setup_with_treasury();
let sender_pk = f.sender.pubkey();
set_status(&mut f.svm, &f.payer, &sender_pk, registry::state::InvestorState::Suspended);
// authority = 送信者本人(delegate ではない)。宛先は Approved トレジャリ。
let ix = transfer_checked_with_hook(
&f.mint.pubkey(),
&f.source_ata,
&treasury_ata,
&f.sender.pubkey(), // authority = 送信者本人
&treasury_owner.pubkey(),
100,
);
let res = send(&mut f.svm, &[ix], &f.payer, &[&f.payer, &f.sender]);
assert!(
res.is_err(),
"Suspended 保有者本人の署名による転送は、宛先が Approved でも失敗しなければならない(免除は delegate 経路のみ)"
);
assert_eq!(token_amount(&f.svm, &f.source_ata), 1_000);
}
例外を実装したら、その例外が広がっていないことをテストで固定する。 これは強い権限を扱うすべての機能に当てはまる原則です。
同様に T-22 の失敗側(test_force_transfer_to_unregistered_treasury_fails)は、回収先が未登録なら強制回収も失敗することを確認します。これは「強制移転でも Hook が必ず CPI される(迂回経路が存在しない)」ことの実証にもなっています。
運用手順の実証(T-23)#
凍結中の口座からは、Permanent Delegate であっても回収できません。Token-2022 が凍結口座の移転自体を拒否するためです。したがって運用手順は「① thaw → ② 強制回収 → ③ 再 freeze」になります。T-23 はこの前提を実証します。
// programs/transfer_hook/tests/test_transfer_hook.rs
// --- 省略 ---(fixture 構築とトレジャリ登録は上記 T-20 と同様)
// 対象投資家を Suspended にし、口座を freeze(凍結発動)する。
set_status(&mut f.svm, &payer, &sender_pk, registry::state::InvestorState::Suspended);
freeze_account(&mut f.svm, &payer, &mint_pk, &source_ata);
let build_force_ix = || {
force_transfer_checked_with_hook(
&mint_pk,
&source_ata,
&treasury_ata,
&payer.pubkey(),
&sender_pk,
&treasury_owner.pubkey(),
1_000,
)
};
// ① Frozen のまま強制回収 → Token-2022 が凍結口座の移転を拒否し失敗。
let ix_frozen = build_force_ix();
let res_frozen = send(&mut f.svm, &[ix_frozen], &payer, &[&payer]);
assert!(res_frozen.is_err(), "凍結中口座からの強制回収は失敗しなければならない");
assert_eq!(token_amount(&f.svm, &source_ata), 1_000);
// ② thaw(回収のため一時解除)→ 強制回収 → 成功。
thaw_account(&mut f.svm, &payer, &mint_pk, &source_ata);
let ix_thawed = build_force_ix();
let res_thawed = send(&mut f.svm, &[ix_thawed], &payer, &[&payer]);
assert!(res_thawed.is_ok(), "thaw 後の強制回収は成功するはず: {res_thawed:?}");
assert_eq!(token_amount(&f.svm, &treasury_ata), 1_000);
assert_eq!(token_amount(&f.svm, &source_ata), 0);
}
このように、テストは実装の検証だけでなく運用手順書の裏付けにもなります。 「凍結中でも回収できる」と誤解したまま運用手順を書くと、緊急時に手順が動きません。
9.5 異常系・攻撃シナリオのテスト#
9.5.1 攻撃ケース表#
ここまでは「正しい使い方」の範囲でのテストでした。本節では、攻撃者が意図的に不正な入力を行うケースを扱います。
なお本節の目的は攻撃手法の解説ではなく、防御が機能することの実証です(第10章の方針に従い、攻撃コードそのものの提示は行いません)。
| # | 要件ID | 攻撃手法 | 期待結果 | 対応テスト関数 |
|---|---|---|---|---|
| T-30 | R-AUTH-01 | 管理者でない鍵が set_investor_status で他人を Approved 化 |
失敗 | test_set_status_by_non_admin_fails |
| T-31 | R-SEC-01 | 偽の InvestorStatus(registry 所有でない別アカウント)を差し込み Approved を詐称 | 失敗 | test_fake_investor_status_account_rejected |
| T-32 | R-SEC-01 | 別投資家(Approved)の正規 InvestorStatus PDA を、未許可者の分として差し込む | 失敗 | test_mismatched_investor_status_pda_rejected |
| T-33 | R-SEC-02 | 転送コンテキスト外から Hook の Execute を直接呼ぶ |
失敗 | test_direct_execute_outside_transfer_rejected |
| T-34 | R-SEC-03 | 追加アカウント(registry program / InvestorStatus)を省略して転送 | 失敗 | test_transfer_missing_extra_accounts_fails |
| T-35 | R-SEC-03 | registry program 位置に偽のプログラム/アカウントを差し込む | 失敗 | test_wrong_registry_program_rejected |
T-31 と T-32 は、いずれも「Approved の InvestorStatus を偽装する」攻撃ですが、狙う穴が異なります。T-31 は所有者チェック(registry が所有していないアカウント)を、T-32 はシードチェック(他人の正規 PDA を流用)を突きます。第7章 7.3 で両方を検証しているため、どちらも拒否されます。
9.5.2 転送コンテキスト外からの直接呼び出し(T-33)#
Hook は本来 Token-2022 から CPI で呼ばれます。しかし Hook プログラム自体は誰でも直接呼び出せるため、転送を伴わない直接呼び出しを拒否する必要があります。
Token-2022 は Execute の CPI 直前にトークンアカウントの transferring フラグを立てます。Hook はこのフラグを検証することで、正規の転送経路から呼ばれたことを確認します。
// programs/transfer_hook/tests/test_attacks.rs
#[test]
fn test_direct_execute_outside_transfer_rejected() {
let mut env = setup_env();
// Token-2022 を介さず、Hook の Execute を直接呼ぶ。
// 実際の転送ではないため、トークン口座の transferring フラグは false のまま。
// → assert_is_transferring が ProgramCalledOutsideOfTransfer で失敗する。
let extra_meta_list = get_extra_account_metas_address(&env.mint.pubkey(), &transfer_hook::ID);
let execute_ix = Instruction {
program_id: transfer_hook::ID,
accounts: vec![
AccountMeta::new_readonly(env.source_ata, false),
AccountMeta::new_readonly(env.mint.pubkey(), false),
AccountMeta::new_readonly(env.dest_ata, false),
AccountMeta::new_readonly(env.sender.pubkey(), false),
AccountMeta::new_readonly(extra_meta_list, false),
AccountMeta::new_readonly(registry::ID, false),
AccountMeta::new_readonly(investor_pda(&env.sender.pubkey()), false),
AccountMeta::new_readonly(investor_pda(&env.receiver.pubkey()), false),
],
data: TransferHookInstruction::Execute { amount: 100 }.pack(),
};
let res = send(&mut env.svm, &[execute_ix], &env.payer, &[&env.payer]);
assert!(res.is_err(), "転送コンテキスト外からの Hook 直接呼び出しは拒否されなければならない");
// 残高は不変(そもそも Hook は残高を動かさないが、状態悪用がないことの確認)。
assert_eq!(token_amount(&env.svm, &env.source_ata), 1_000);
}
このテストでは、攻撃者が正規のアカウント一式を正しく揃えている点に注意してください。アカウントの誤りではなく、transferring フラグが立っていないことだけが失敗要因になるよう構成しています。
9.5.3 偽陽性の排除:失敗理由をエラーコードで確認する#
異常系テストで最も重要な工程です。is_err() が真であっても、それが意図した防御による失敗とは限りません。そこで、実行ログに出力されるカスタムエラーコードを確認しました。
| テスト | 観測したエラーコード | 対応するエラー |
|---|---|---|
| T-33(転送外の直接 Execute) | 0x7dc8348f |
TransferHookError::ProgramCalledOutsideOfTransfer |
| T-31(偽 InvestorStatus) | 0x7dc8348c |
TransferHookError::IncorrectAccount(owner ≠ registry::ID) |
いずれも Hook が返した想定どおりのエラーであり、署名不足やアカウント不足といった別要因で失敗したのではないことを確認しています。
なお T-34(追加アカウントの省略)は、Hook に到達する前に Token-2022 側が ExtraAccountMetaList を解決できず失敗します。これも防御としては正しい挙動です。「どの層で止まったか」まで把握しておくと、後の改修時に安全性が崩れたことに気づけます。
9.5.4 攻撃系テストを書くときの原則#
本節のテスト群から一般化できる原則を挙げます。
- 成功しないことだけでなく、状態が変化していないことを確認する。 残高・アカウントの存在を明示的に検証します。
- 失敗要因を1つに絞る。 残高や署名は十分に揃えたうえで、検証したい防御だけが失敗要因になるよう構成します。
- 失敗理由をエラーコードまで確認する。 これを省くと、防御が壊れてもテストが緑のままになる恐れがあります。
- 例外を実装したら、その例外が限定されていることをテストで固定する(9.4.4項)。
9.6 devnet での E2E リハーサル(T-40)#
LiteSVM は高速ですが、実クラスタ固有の挙動(レント計算、アカウントサイズの再割り当て、トランザクションの確定)までは再現しません。リリース判断の前に、実クラスタで一度通しておく必要があります。
| # | 要件ID | シナリオ | 期待結果 | 確認方法 |
|---|---|---|---|---|
| T-40 | R-FREEZE-01 / 第6章 | create-mint.ts を devnet で実行し、4拡張+Metadata 本体付きミントを作成 |
成功 | 入金 → 実行 → TX署名取得 → RPC getAccountInfo(jsonParsed)で拡張確認 |
9.6.1 事前準備:devnet の入金は自動化しにくい#
実行前にテスト用の鍵へ SOL を入金する必要があります。ここは手順書として正直に書いておきます。執筆時点(2026年7月)では、devnet の自動 airdrop は事実上ゲートされていました。
| 入金手段 | 執筆時点の結果 |
|---|---|
| 公式 RPC への airdrop リクエスト | HTTP 429(レート制限) |
| faucet.solana.com | GitHub 認証が必要 |
Solana CLI の airdrop |
レート制限 |
このため、本書の検証では入金作業を人が対話的に実施しました。CI に組み込む場合は、事前に入金済みの鍵を用意しておく運用を検討してください。
なお鍵の扱いは第5章の統制に従います。本検証で使用したのはローカル生成のテスト用鍵であり、リポジトリには含めていません(.gitignore で除外)。
9.6.2 実行手順#
支払者の鍵を環境変数で指定して、第6章のスクリプトを実行します。
RPC_URL=https://api.devnet.solana.com \
PAYER_KEYPAIR=.devnet-e2e/payer.json \
npx tsx scripts/create-mint.ts
9.6.3 結果の確認#
実行の結果、次のミントが devnet 上に作成されました(確認日: 2026-07-23)。
| 項目 | 値 |
|---|---|
| 作成したミント | 5fcLh2uW9uRwizuWgKCNmdkpHmKGD23jXV2LaHuWHcAQ |
| ミント作成TX署名 | 4HYo78DDmujE6efkLMvvJQMRKMk3aZzkUgqsAdHBn4rc2PWF319KeQahVBW66MSQeV7tgY7kLZujSxBnprqiYA3n |
確認は2段階で行います。
第1に、トランザクションが確定したことを getSignatureStatuses で確認します。結果は confirmationStatus: finalized / err: null でした。err: null まで見ることが重要です。 トランザクションは、確定していてもプログラム側でエラーになっている場合があります。
第2に、意図した拡張が実際に付いているかを getAccountInfo(jsonParsed 形式)で確認します。Explorer の表示と同じ情報です。
| 確認項目 | 結果 |
|---|---|
| owner | Token-2022(TokenzQ…) |
| decimals / 権限 | decimals=0、mint authority / freeze authority = payer |
transferHook |
programId = 7wHY…ERY6(本 Hook)、authority = payer |
permanentDelegate |
delegate = payer |
defaultAccountState |
state = frozen |
metadataPointer |
metadataAddress = ミント自身 |
tokenMetadata |
name=RWA Private Bond (Reference) / symbol=RWAB / uri=https://example.com/rwa/private-bond.json |
この検証で最も価値があったのは、9.1.3項で述べた Surfpool の制約が devnet では発生しなかったことの確認です。 可変長 Metadata 本体 TLV の realloc が正常に完了し、第6章で採用した二段階レント方式が実クラスタで正しく機能することを実証できました。
9.7 CI による機械的ゲート#
9.7.1 なぜ機械的ゲートが必要か#
第5章で述べたとおり、AI コーディングエージェントの利用が一般化した現在、「レビューで気づく」ことに依存した品質管理は成立しにくくなっています。生成されたコードは一見自然で、人間の注意力では見落としが生じます。
そこで、AI 生成か人間の記述かを問わず、すべてのコードが同じ関門を通る構成にします。本書では次の5つをゲートとします。
| ゲート | コマンド | 目的 |
|---|---|---|
| ① ビルド | anchor build --no-idl |
コンパイルが通ること。テストが読む .so の生成 |
| ② 全テスト | cargo test |
26件のテストが全て通ること |
| ③ lint | cargo clippy -- -D warnings |
警告をエラーとして扱い、品質低下を防ぐ |
| ④ 型チェック | npm run typecheck(tsc --noEmit) |
TypeScript クライアントの型整合 |
| ⑤ 依存監査 | npm audit / cargo audit |
既知の脆弱性・不正なパッケージ混入の検知(第5章5.) |
ゲート①②の順序には依存関係があります。9.3.1項で述べたとおり、テストが include_bytes! でビルド成果物を読み込むため、ビルドを先に実行しなければテストは動きません。
9.7.2 ワークフロー定義#
以下は本書のリファレンス実装で実際に使用している定義です。
# .github/workflows/ci.yml
# --- 省略 ---(ヘッダコメントと on: / concurrency: の定義)
env:
ANCHOR_VERSION: 1.1.2
SOLANA_VERSION: 3.1.13
NODE_VERSION: 24.15.0
jobs:
# ゲート①②③: Rust側(ビルド → 全テスト → lint)。
rust:
name: build / test / clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# rust-toolchain.toml の channel(1.89.0)が自動で採用される。
# components も同ファイルの指定(rustfmt, clippy)が入る。
- name: Show Rust toolchain
run: rustc --version && cargo --version && cargo clippy --version
# --- 省略 ---(actions/cache@v4 による cargo キャッシュ)
# Solana CLI(Agave)。cargo-build-sbf がプログラムのビルドに必要。
- name: Install Solana CLI ${{ env.SOLANA_VERSION }}
run: |
sh -c "$(curl -sSfL https://release.anza.xyz/v${SOLANA_VERSION}/install)"
echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH"
- name: Show Solana version
run: solana --version
# Anchor CLI は公式リリースのビルド済みバイナリを使う(ソースビルドは数分かかるため)。
# 配布元 otter-sec/anchor は anchor-cli crate の repository と一致し、
# crates.io の owner に solana-foundation-tech が含まれる(確認日: 2026-07-24)。
- name: Install Anchor CLI ${{ env.ANCHOR_VERSION }}
run: |
curl -sSfL -o /usr/local/bin/anchor \
"https://github.com/otter-sec/anchor/releases/download/v${ANCHOR_VERSION}/anchor-${ANCHOR_VERSION}-x86_64-unknown-linux-gnu"
chmod +x /usr/local/bin/anchor
anchor --version
# ゲート①: ビルド。transfer_hook はネイティブ実装でIDLを持たないため --no-idl。
# このステップが生成する target/deploy/*.so を、次のテストが include_bytes! で読み込む。
- name: Gate 1 - anchor build
run: anchor build --no-idl
# ゲート②: 全テスト(LiteSVM)。
- name: Gate 2 - cargo test
run: cargo test
# ゲート③: lint。既知の result_large_err(LiteSVM 由来)のみ許容し、
# それ以外の警告はエラーとして落とす。
- name: Gate 3 - cargo clippy
run: cargo clippy --workspace --tests -- -D warnings -A clippy::result_large_err
# .github/workflows/ci.yml(続き)
# ゲート④: TypeScript クライアントの型チェック。
typescript:
name: typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
# package-lock.json のとおりに厳密インストールする(バージョン固定の担保)。
- name: Install dependencies (locked)
run: npm ci
- name: Gate 4 - tsc --noEmit
run: npm run typecheck
# 依存監査: 既知の脆弱性・不正なパッケージ混入の検知(手順書 第5章5.)。
audit:
name: dependency audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: npm audit
run: npm audit --audit-level=high
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: cargo audit
run: cargo audit
9.7.3 バージョン固定と配布元の確認#
CI で外部からバイナリを取得する箇所は、サプライチェーン上の攻撃面になります。第5章の統制に従い、次を守っています。
- バージョンを固定する。
env:に定義した値は第1章のバージョン表と一致させます。latestを参照しません。 - 配布元を確認する。 Anchor CLI の取得元
otter-sec/anchorは、crates.io のanchor-cliが宣言する repository と一致し、crate の owner にsolana-foundation-techが含まれることを確認しました(確認日: 2026-07-24)。 - npm は
npm ciを使う。package-lock.jsonのとおりに厳密インストールし、バージョンの浮動を防ぎます。
注意(パッケージ名の取り違え): crates.io に存在する
avmという名前のクレートは、Anchor のバージョン管理ツールとは無関係の別パッケージです(確認日: 2026-07-24)。CI でcargo install avmと書くと意図しないパッケージが入ります。Anchor の avm は Anchor リポジトリに含まれるため、導入する場合はリポジトリを明示してください。これは第5章で述べた「正規品に似た名前のパッケージ」の実例です。
9.7.4 実行結果(検証範囲の明示)#
本節の内容は、次の2段階で検証しています。それぞれ検証した範囲が異なるため、区別して記載します。
第1段階:各ゲートコマンドのローカル実行(確認日: 2026-07-24)
上表の5コマンドを開発機で個別に実行し、すべて成功することを確認しました。この段階で検証されるのはコマンド自体の妥当性であり、ワークフロー定義(YAML)の正しさは含まれません。
第2段階:GitHub Actions 実機での実行(確認日: 2026-07-24)
ワークフロー定義を private リポジトリへ push し、Actions 上で実際に実行しました。結果は 3ジョブすべて成功(conclusion: success)です。この段階で、YAML の構文・ステップの依存関係・クリーン環境での再現性までが検証されています。
Actions 上でのテスト実行結果は次のとおりで、ローカルと同一の26件が通っています。
running 3 tests
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.57s
running 6 tests
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.58s
running 15 tests
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.63s
Actions 上で報告されたツールバージョンは anchor-cli 1.1.2 / solana-cli 3.1.13 (client:Agave) であり、第1章のバージョン表と一致します。クリーン環境で同じバージョンが再現されることまで確認できた点に、この段階の価値があります。
依存監査の結果は次のとおりです。cargo audit は 391 個の依存を走査し、脆弱性0件でした(6件の警告は unmaintained 等の情報レベル)。npm audit は moderate 2件のみで、--audit-level=high の基準を通過しています。
9.7.5 ゲートが実際に検出した例#
本 CI の導入時、clippy ゲートがテストコードの改善点を1件検出しました。Option::map_or(true, ...) を、より意図が明確な Option::is_none_or(...) に置き換えるべきという指摘です。
動作上のバグではありませんが、「警告をエラーとして扱う」設定にしていなければ見過ごされていた箇所です。修正後、26件のテストは全て通過しています。
このように、機械的ゲートの価値は重大なバグの検出だけではありません。品質の緩やかな低下を、人の注意力に頼らず継続的に止めることにあります。
9.8 ファジングの位置づけ#
ここまでのテストは、いずれも開発者が想定したケースを検証するものです。しかし攻撃者は、開発者が想定しなかった入力を試します。この「想定の外側」を機械的に探索する手法がファジングです。
ファジングは、プログラムに大量のランダム/半ランダムな入力を与え、パニックや不変条件の破れを探します。境界値やオーバーフローなど、人手のテストで漏れやすい領域に有効です。
Solana プログラム向けのファジングフレームワークとしては Trident があります(確認日: 2026-07-24)。
配布元について: Trident は
Ackee-Blockchain/tridentで公開されており、crates.io のtrident-cliが宣言する repository も同一です。これは Solana 公式 org(solana-foundation / anza-xyz / solana-program)による提供ではなく、Ackee Blockchain 社によるサードパーティ製フレームワークです。導入する場合は、第5章の依存審査の観点(発行元・保守状況・内容確認)で評価してください。
本書のリファレンス実装ではファジングを実施していません。 したがって本節は位置づけの紹介に留め、具体的な導入手順や実行結果は掲載しません(未検証の手順を掲載しない方針によります)。
実務での位置づけとしては、次のように考えるのが妥当です。ファジングは本章のテスト群を置き換えるものではなく、補完するものです。要件ベースのテスト(9.2節)で「仕様どおり動くこと」を保証したうえで、ファジングで「仕様に書かれていない入力への耐性」を探索します。監査(第10章)を受ける規模のプロジェクトでは、導入を検討してください。
9.9 本章のまとめ#
本章で示したテストの考え方を整理します。
- 層を使い分ける。 LiteSVM で速く回し、devnet で実クラスタ固有の挙動を確認します。層ごとに再現されない要素があることを前提に切り分けます(9.1.3項の Surfpool の例)。
- 要件から引く。 第3章の要件に ID を付け、対応表で未カバーを機械的に検出します(全12要件をカバー)。
- 失敗すべきケースを網羅する。 成功1件ではなく、失敗すべき組合せを尽くします。
- 失敗理由まで確認する。
is_err()だけでは、防御が壊れてもテストが緑のままになり得ます。 - 例外には限定の証明を付ける。 強制回収の免除が delegate 経路だけに働くことを、専用のテストで固定しました。
- 人の注意力に頼らない。 CI ですべてのコードを同じ関門に通します(第5章と接続)。
次章では、本章のテストで守った性質を、セキュリティと監査の観点から再整理します。
9.10 本章の自己チェック表#
| 観点 | 結果 |
|---|---|
| スタイル規則違反 | なし(です・ます調、一文概ね80字以内、コードブロックに言語指定とファイルパス付与、省略は // --- 省略 --- で明示、相互参照は番号、価格・投資リターン・マーケティング語彙・法的判断示唆なし) |
[要検証] の残数 |
0件。9.7 の CI は Actions 実機で成功を確認済み(確認日 2026-07-24)。9.8 のファジングは未実施のため、手順を書かず位置づけの紹介に留めた(未検証事項を本文に書いていないためタグ不要) |
| バージョン表との不一致 | なし(Anchor 1.1.2 / Solana CLI 3.1.13 / Rust 1.89.0 / Node 24.15.0 / LiteSVM 0.10.0 はいずれも第1章バージョン表と一致。CI の env: も同値。バージョン指定に「latest」「最新版」を使用していない。CI 定義中の ubuntu-latest は GitHub Actions のランナー種別を指すラベルであり、依存のバージョン指定ではない) |
| コードの出所 | すべてリポジトリからの転記(programs/registry/tests/test_registry.rs / programs/transfer_hook/tests/test_transfer_hook.rs / programs/transfer_hook/tests/test_attacks.rs / .github/workflows/ci.yml)。実行結果も実際の出力からの転記 |
| 実行検証 | cargo test 26件成功(ローカル・Actions 双方)。CI 5ゲートはローカル実行と Actions 実機の両方で成功。devnet T-40 は TX finalized・拡張5種を確認済み |
| 配布元確認 | Anchor CLI(otter-sec/anchor、crates.io owner に solana-foundation-tech)/ Agave(anza-xyz)/ Trident(Ackee-Blockchain、サードパーティである旨を本文に明記)をいずれも確認(確認日 2026-07-24) |