第7章 コンプライアンスプログラムの実装(Anchor)#
本章では、第6章で作成したミントに接続する2つのオンチェーンプログラムを実装します。1つは投資家の許可状態を管理するレジストリ(registry)、もう1つは転送時に許可状態を検証するTransfer Hook(transfer_hook)です。この2つで、「許可済み投資家間のみの移転」と「発行体による強制回収」を実現します。
本章のコードは、リポジトリの検証済みソースからの転記です。両プログラムは LiteSVM による統合テスト(合計26テスト)が全て成功しており(第9章)、説明の都合でコードを変更することはしていません。
7.1 Transfer Hookインターフェースの仕様#
Transfer Hook は、Token-2022 が転送時に外部プログラムを呼び出す仕組みです。呼び出される側のプログラムは、spl-transfer-hook-interface(org: solana-program、確認日: 2026-07-23)に準拠して実装します。
まず、誤解しやすい2つの動作を確認します(確認日: 2026-07-23、出典: solana-program.com/docs/token-2022/extensions)。
- Hook プログラムは転送を「実行」しません。 転送は Token-2022 本体が行います。Hook は転送の完了状態を反映したアカウント群を受け取り、検証を行う役割です。
- Hook が失敗すると、転送全体が失敗します。 Hook が
Errを返すと、その失敗が Token-2022 に伝播し、転送は成立しません。これが移転制限の技術的な土台です。
7.1.1 なぜ #[program] ディスパッチを使わないか#
本 Hook は Anchor で書く一方、Anchor の #[program] マクロによる命令ディスパッチは使いません。理由は判別子(discriminator)の形式が異なるためです。
- SPL Transfer Hook インターフェースの命令判別子は、命令名の sha256 に由来します。
- Anchor の
#[program]が期待する8バイト判別子は、Anchor 独自の形式です。
この2つは非互換のため、SPL インターフェースに準拠するには、SPL リファレンス実装と同様に ネイティブ entrypoint + TransferHookInstruction::unpack によるディスパッチを用います。Anchor は、レジストリの型(InvestorStatus)の読み取りにのみ再利用します。
// programs/transfer_hook/src/lib.rs
entrypoint!(process_instruction);
// SPL Transfer Hook インターフェースの命令ディスパッチ。
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
input: &[u8],
) -> ProgramResult {
let instruction = TransferHookInstruction::unpack(input)?;
match instruction {
// Token-2022 が転送時に呼び出す本体。移転可否をここで判定する。
TransferHookInstruction::Execute { amount } => {
process_execute(program_id, accounts, amount)
}
// ExtraAccountMetaList PDA を作成し、追加で必要なアカウント
// (送信者・受信者双方の InvestorStatus PDA)を宣言する。
TransferHookInstruction::InitializeExtraAccountMetaList {
extra_account_metas,
} => process_initialize_extra_account_meta_list(program_id, accounts, &extra_account_metas),
// 追加アカウント一覧の更新。本リファレンスでは初期化と同経路で処理する。
TransferHookInstruction::UpdateExtraAccountMetaList {
extra_account_metas,
} => process_update_extra_account_meta_list(program_id, accounts, &extra_account_metas),
}
}
7.1.2 ExtraAccountMetaList の役割#
Transfer Hook の課題は、「Hook が検証に必要とする追加アカウントを、転送の呼び出し側がどうやって知るか」です。SPL インターフェースは、これを ExtraAccountMetaList という仕組みで解決します。
- Hook プログラムは、ミントごとに1つの ExtraAccountMetaList PDA を持ちます。ここに「Execute 時に追加で必要なアカウント」の定義を書き込んでおきます。
- クライアントは、通常の Transfer と同じアカウントだけを指定すれば済みます。追加アカウントは、インターフェースのヘルパ(オンチェーン・オフチェーン双方で利用可能)が ExtraAccountMetaList から解決します(確認日: 2026-07-23、出典: solana-program.com/docs/token-2022/extensions)。
本書の Hook では、この追加アカウントとして「registry プログラム」と「送信者・受信者双方の InvestorStatus PDA」を宣言します(7.3.2)。
7.2 レジストリプログラムの実装#
レジストリは、投資家の許可状態を管理する Anchor プログラムです。こちらは通常の Anchor プログラム(#[program])として実装します。
7.2.1 状態設計(Config / InvestorStatus / InvestorState)#
状態は3つの型で構成します。許可状態は Approved(許可済み)と Suspended(一時停止)の2値です。
// programs/registry/src/state.rs
#[derive(AnchorSerialize, AnchorDeserialize, InitSpace, Clone, Copy, PartialEq, Eq, Debug)]
pub enum InvestorState {
// 許可済み。移転・受領が可能な状態。
Approved,
// 一時停止。KYC失効・凍結等により移転を認めない状態。
Suspended,
}
// レジストリ全体の設定。CONFIG_SEED から導出される単一のPDA。
#[account]
#[derive(InitSpace)]
pub struct Config {
// レジストリ管理者。投資家の登録・状態変更を行える唯一の権限者。
// 実運用ではマルチシグのアドレスを設定することを想定する(設計書3.1.5)。
pub admin: Pubkey,
// Config PDAのbump。再導出コストを避けるため保存する。
pub bump: u8,
}
// 投資家1名分の許可状態レコード。
// [INVESTOR_SEED, investor] から導出されるPDAに格納する。
#[account]
#[derive(InitSpace)]
pub struct InvestorStatus {
// 対象となる投資家のウォレットアドレス。
pub investor: Pubkey,
// 現在の許可状態(approved / suspended)。
pub state: InvestorState,
// このPDAのbump。
pub bump: u8,
}
PDA のシードは定数として定義します。Config はワークスペースに1つ、InvestorStatus は投資家1名につき1つ導出されます。
// programs/registry/src/constants.rs
// Config PDA(レジストリ設定)のシード。ワークスペースに1つだけ存在する。
#[constant]
pub const CONFIG_SEED: &[u8] = b"config";
// InvestorStatus PDA(投資家1名分の許可状態)のシード接頭辞。
// 完全なシードは [INVESTOR_SEED, investor.key()] で構成する。
#[constant]
pub const INVESTOR_SEED: &[u8] = b"investor";
7.2.2 命令と権限constraint(has_one = admin)#
レジストリは4つの命令を持ちます。投資家の登録・状態変更は、管理者のみが行えます。
// programs/registry/src/lib.rs
#[program]
pub mod registry {
use super::*;
// レジストリを初期化し、署名者を管理者として登録する。
pub fn initialize_config(ctx: Context<InitializeConfig>) -> Result<()> {
instructions::initialize_config::handle_initialize_config(ctx)
}
// 投資家を新規登録する(KYC完了後、管理者が実行)。
pub fn register_investor(
ctx: Context<RegisterInvestor>,
investor: Pubkey,
state: InvestorState,
) -> Result<()> {
instructions::register_investor::handle_register_investor(ctx, investor, state)
}
// 既存投資家の許可状態を変更する(凍結/凍結解除)。
pub fn set_investor_status(
ctx: Context<SetInvestorStatus>,
new_state: InvestorState,
) -> Result<()> {
instructions::set_investor_status::handle_set_investor_status(ctx, new_state)
}
// 管理者権限を別アドレスへ移管する。
pub fn transfer_admin(ctx: Context<TransferAdmin>, new_admin: Pubkey) -> Result<()> {
instructions::transfer_admin::handle_transfer_admin(ctx, new_admin)
}
}
権限チェックは、Anchor の has_one constraint で行います。次の set_investor_status の口座定義がその典型です。config の admin フィールドと署名者 admin の一致を、Anchor が自動で検証します。
// programs/registry/src/instructions/set_investor_status.rs
#[derive(Accounts)]
pub struct SetInvestorStatus<'info> {
// 管理者。状態変更に署名する。
pub admin: Signer<'info>,
// Config PDA。has_one で管理者権限を検証する。
#[account(
seeds = [CONFIG_SEED],
bump = config.bump,
has_one = admin @ RegistryError::Unauthorized,
)]
pub config: Account<'info, Config>,
// 更新対象のInvestorStatus PDA。
// seeds に investor_status.investor を用いることで、
// 渡されたPDAが正しい投資家のものであることを保証する(account confusion対策)。
#[account(
mut,
seeds = [INVESTOR_SEED, investor_status.investor.as_ref()],
bump = investor_status.bump,
)]
pub investor_status: Account<'info, InvestorStatus>,
}
ここには、セキュリティ上の定番論点が2つ埋め込まれています。
- 署名者検証+権限チェック:
admin: Signer<'info>で署名を要求し、has_one = adminで「その署名者が管理者本人であること」を検証します。両方が揃って初めて権限昇格を防げます。 - account confusion 対策:
investor_statusのseedsにinvestor_status.investorを用いることで、渡された PDA が「そのレコードが指す投資家のためのもの」であることを保証します。無関係な PDA を差し込む攻撃を、シード検証で弾きます。
7.2.3 二重登録防止(init 制約)#
投資家の新規登録では、init 制約と、シードに投資家アドレスを含める設計により、同一投資家の二重登録を自動的に防ぎます。
// programs/registry/src/instructions/register_investor.rs
// 作成するInvestorStatus PDA。investor をシードに含めるため、
// 同一投資家に対する二重登録は init 制約により自動的に失敗する。
#[account(
init,
payer = admin,
space = 8 + InvestorStatus::INIT_SPACE,
seeds = [INVESTOR_SEED, investor.as_ref()],
bump
)]
pub investor_status: Account<'info, InvestorStatus>,
init は、既に存在するアカウントに対しては失敗します。PDA アドレスは投資家アドレスから一意に決まるため、同じ投資家を2回登録しようとすると、2回目は init 制約で失敗します。
7.3 Hook本体の実装#
ここからは Transfer Hook 本体(transfer_hook/src/lib.rs)を見ます。転送時に、送信者・受信者双方の許可状態を検証します。
7.3.1 Execute のアカウント順と検証3段#
Token-2022 は転送時、Hook の Execute 命令を、決まった順序のアカウントで呼び出します。先頭4つ(source / mint / destination / authority)は Token-2022 が渡します。それに続く追加アカウントは、ExtraAccountMetaList から解決されたものです。
// programs/transfer_hook/src/lib.rs
// アカウント順(SPL インターフェース Execute + extra_account_metas() の宣言順):
// 0: source トークンアカウント
// 1: mint
// 2: destination トークンアカウント
// 3: authority(通常移転=送信者本人 / 強制移転=permanent delegate。許可判定には用いない)
// 4: ExtraAccountMetaList PDA(validation account)
// 5: registry プログラム
// 6: 送信者(source 口座 owner)の InvestorStatus PDA
// 7: 受信者(destination 口座 owner)の InvestorStatus PDA
fn process_execute(_program_id: &Pubkey, accounts: &[AccountInfo], _amount: u64) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let source_account = next_account_info(account_info_iter)?;
let mint_account = next_account_info(account_info_iter)?;
let destination_account = next_account_info(account_info_iter)?;
// account #3 は authority(通常移転=送信者本人 / 強制移転=permanent delegate)。
let authority = next_account_info(account_info_iter)?;
let _extra_meta_list = next_account_info(account_info_iter)?;
let registry_program = next_account_info(account_info_iter)?;
let source_investor_status = next_account_info(account_info_iter)?;
let destination_investor_status = next_account_info(account_info_iter)?;
// --- 1. 「転送中」フラグの検証(設計書 第7章 / security.md #16)---
// Hook が実際の転送コンテキスト外から呼ばれていないことを確認する。
// これがないと、攻撃者が任意のタイミングで Hook を呼び、状態を悪用し得る。
assert_is_transferring(source_account)?;
assert_is_transferring(destination_account)?;
// --- 2. 追加アカウントの正当性検証(account confusion 対策)---
// 渡された InvestorStatus PDA が、本当に registry が所有し、
// 期待するシードから導出された PDA であることを確認する。
if *registry_program.key != registry::ID {
return Err(TransferHookError::IncorrectAccount.into());
}
// 送信者・受信者ウォレット(各トークンアカウントの owner)をアカウントデータから取り出す。
// ExtraAccountMetaList 側も同じく口座データ(offset 32)から PDA を導出しているため、
// ここでも口座 owner を用いて検証し、通常移転・delegate 強制移転の双方で一致させる。
let source_owner = token_account_owner(source_account)?;
let destination_owner = token_account_owner(destination_account)?;
verify_investor_status_pda(source_investor_status, &source_owner)?;
verify_investor_status_pda(destination_investor_status, &destination_owner)?;
// --- 3. 強制回収(Permanent Delegate)経路の判定 ---
// --- 省略 ---(強制措置の判定の詳細は 7.5 節で解説します)
let is_permanent_delegate = authority_is_permanent_delegate(mint_account, authority.key)?;
// --- 4. 許可状態の検証 ---
// 送信者側:delegate 経路のときのみ免除。通常経路では従来通り Approved を要求する。
if is_permanent_delegate {
// --- 省略 ---
msg!(
"FORCE_TRANSFER via permanent delegate: source_owner={}, destination_owner={}",
source_owner,
destination_owner
);
} else {
// 通常経路:送信者本人も Approved でなければならない。
assert_investor_approved(source_investor_status)?;
}
// 受信者側:経路によらず常に Approved を要求する(回収先トレジャリも Approved 必須)。
assert_investor_approved(destination_investor_status)?;
Ok(())
}
Execute の検証は、次の3段構えです。
-
「転送中」フラグの検証: トークンアカウントの
TransferHookAccount拡張が持つtransferringフラグを確認します。Token-2022 は Execute の CPI 直前にこのフラグを立てます。フラグが false のとき(=転送コンテキスト外からの直接呼び出し)は失敗させます。これがないと、攻撃者が任意のタイミングで Hook を呼び出せてしまいます。// programs/transfer_hook/src/lib.rs // トークンアカウントの TransferHookAccount 拡張を読み、transferring が true か検証する。 fn assert_is_transferring(token_account: &AccountInfo) -> ProgramResult { let data = token_account.try_borrow_data()?; let state = StateWithExtensions::<Token2022Account>::unpack(&data) .map_err(|_| ProgramError::from(TransferHookError::IncorrectAccount))?; let extension = state .get_extension::<TransferHookAccount>() .map_err(|_| ProgramError::from(TransferHookError::IncorrectAccount))?; if !bool::from(extension.transferring) { // 転送コンテキスト外からの呼び出し。 return Err(TransferHookError::ProgramCalledOutsideOfTransfer.into()); } Ok(()) } -
追加アカウントの正当性検証: registry プログラムのアドレス一致と、
InvestorStatusPDA の所有者・シード検証を行います(7.3.2)。 -
許可状態の検証: 送信者・受信者双方の
InvestorStatusがApprovedであることを検証します。ただし送信者側は、強制回収経路のときのみ免除します(7.5)。
7.3.2 追加アカウントの解決(ExtraAccountMeta)#
7.1.2で述べた ExtraAccountMetaList には、次の3つの追加アカウントを宣言します。宣言順が、Execute 時の実アカウント順(インデックス5以降)になります。
// programs/transfer_hook/src/lib.rs
// 追加アカウント:
// - index 5: registry プログラム(InvestorStatus の所有者。external PDA 導出の基準)
// - index 6: 送信者(owner = account #3)の InvestorStatus PDA
// - index 7: 受信者(destination の owner)の InvestorStatus PDA
送信者・受信者の InvestorStatus PDA は、各トークンアカウントのデータ内の owner(SPL Token アカウントレイアウトの offset 32)をシードに用いて、registry を program index とする external PDA として導出します。
// programs/transfer_hook/src/lib.rs
// index 7: 受信者の InvestorStatus。
// destination(account #2)のアカウントデータ内 owner(offset 32, 長さ 32)を
// シードに用いて導出する。
ExtraAccountMeta::new_external_pda_with_seeds(
5,
&[
Seed::Literal {
bytes: registry::constants::INVESTOR_SEED.to_vec(),
},
Seed::AccountData {
account_index: 2,
data_index: 32,
length: 32,
},
],
false,
false,
)?,
渡された PDA の正当性は、Execute 内で次のように検証します。期待するシードから導出した PDA と一致すること、かつ registry が所有していることの両方を確認します。所有者が registry でない場合(未登録=未初期化 or 別プログラム所有)は失敗します。
// programs/transfer_hook/src/lib.rs
// 渡された InvestorStatus PDA が、registry のシードから investor 分として
// 導出された正しい PDA であることを検証する。
fn verify_investor_status_pda(
investor_status: &AccountInfo,
investor: &Pubkey,
) -> ProgramResult {
let (expected, _bump) = investor_status_pda(investor);
if *investor_status.key != expected {
return Err(TransferHookError::IncorrectAccount.into());
}
// registry が所有していること(未登録なら所有者が異なる、または未初期化)。
if *investor_status.owner != registry::ID {
return Err(TransferHookError::IncorrectAccount.into());
}
Ok(())
}
7.3.3 【間違えやすいポイント】送信者PDAは authority 由来でなく owner 由来#
ここが、本 Hook 実装で最も間違えやすい点です。
送信者の InvestorStatus PDA は、Execute の authority(account #3)からではなく、source トークンアカウントのデータ内の owner(offset 32)から導出します。
一見すると、送信者=転送に署名した authority と考えたくなります。通常の移転(送信者本人が署名)ではこれは一致します。しかし、Permanent Delegate による強制移転では、account #3 が delegate になり、送信者(source 口座の実所有者)と一致しません。
この点を authority 由来で実装してしまうと、強制移転時に、追加アカウントの解決(resolver)段階で導出アドレスが食い違い、AccountResolutionError::IncorrectAccount(0xa261c2c0)で失敗します(検証済み。出典: docs/test-cases-ch9.md 4.1、テスト T-20)。
そこで本実装は、送信者側も口座データの owner から導出するように修正しています(受信者側と対称)。こうすることで、通常移転・delegate 移転のいずれでも「送信者本人」の許可状態を正しく検証できます。
// programs/transfer_hook/src/lib.rs
// index 6: 送信者(source トークン口座の owner)の InvestorStatus。
// registry(program_index = 5)が所有する PDA を、シード
// [INVESTOR_SEED, owner_pubkey] で導出する。
// owner は source(account #0)のアカウントデータ内 owner(offset 32, 長さ 32)から取る。
// ※ account #3(authority)ではなく source 口座の owner を用いる理由:
// Permanent Delegate による強制移転では account #3 が delegate になり、
// 送信者(口座の実所有者)と一致しない。口座データから owner を読むことで、
// 通常移転・delegate 移転のいずれでも「送信者本人」の許可状態を検証できる
// (設計書 7.5 強制回収 / 第9章 T-20〜23)。
ExtraAccountMeta::new_external_pda_with_seeds(
5,
&[
Seed::Literal {
bytes: registry::constants::INVESTOR_SEED.to_vec(),
},
Seed::AccountData {
account_index: 0,
data_index: 32,
length: 32,
},
],
false,
false,
)?,
Execute 内の owner 読み取りも、同じく口座データ(offset 32)から行い、resolver 側と一致させます。
// programs/transfer_hook/src/lib.rs
// SPL Token(Token-2022)アカウントの owner(offset 32)を取得する。
// Token-2022 は拡張付きでも base の Account レイアウト(mint[0..32], owner[32..64])は共通。
fn token_account_owner(token_account: &AccountInfo) -> Result<Pubkey, ProgramError> {
let data = token_account.try_borrow_data()?;
let state = StateWithExtensions::<Token2022Account>::unpack(&data)
.map_err(|_| ProgramError::from(TransferHookError::IncorrectAccount))?;
Ok(state.base.owner)
}
7.4 SAS連携パターン(概念)#
自前のレジストリの代わりに、あるいは併用で、Solana Attestation Service(SAS)等のアテステーション(証明)を検証する設計も考えられます。KYC プロバイダが投資家に対して発行したアテステーションを、Hook が転送時に検証する構成です。
本書のリファレンス実装では、SAS連携は未実装です。 本節は設計方針の提示にとどめ、コードは掲載しません(概念実装)。
自前レジストリ・アテステーション参照・併用の3パターンの比較と、本書がレジストリ(パターン a)を選定した理由は、第3章 3.4 で述べました。本節では、そのうちアテステーション参照を採る場合に固有の設計上の要点を補足します。
- 発行者の信頼と管理: 信頼の対象が「発行体(管理者鍵)」から「アテステーション発行者」に移ります。誰を発行者として認めるかの管理が、設計の要点になります。
- 失効の扱い: 一度発行したアテステーションを、どの条件でどう失効させるか(および Hook 側でその失効をどう検知するか)を設計する必要があります。
- 併用時の責務分担: レジストリで発行体の統制を効かせつつ、アテステーションで外部プロバイダの審査結果を取り込む場合、両者の優先順位と整合のとり方を定めます。
いずれを採るかは、信頼モデル・運用体制・法域要件に依存します。SAS の具体的なアカウント構造・命令は仕様変更の可能性があるため、採用時には公式の一次情報で最新仕様を確認してください(第8章のオフチェーン連携と併せて検討)。
7.5 発行体による強制措置(Permanent Delegate)#
第6章で設定した Permanent Delegate を用いると、発行体は投資家の承認なしにトークンを移転・回収できます。ここでは、この強制回収を Hook の許可判定とどう整合させるかを説明します。これは本章で最も慎重な設計判断を要する部分です。
7.5.1 強制回収の経路と、Hookは迂回しないという原則#
強制回収は、Permanent Delegate が transfer_checked の authority となって実行します。重要なのは、Permanent Delegate による移転も Token-2022 の通常の転送経路であり、Transfer Hook は同様に CPI されるという点です。
したがって、Hook を迂回する例外経路は作りません。強制回収であっても Hook は必ず走り、後述の検証(受信者の Approved、transferring フラグ、PDA 正当性)は維持されます。これは、コンプライアンス保証を強制回収時にも崩さないための原則です(承認済み設計方針。出典: docs/test-cases-ch9.md 4.1)。
7.5.2 設計決定:delegate 経路のみ送信者側 Approved 検証を免除#
ここで問題が生じます。強制回収の対象は、多くの場合「Suspended にした不正保有者」です。もし送信者側にも Approved を必須にすると、Suspended の保有者からの回収ができず、強制回収機能そのものが成立しません。
そこで本書は、次の設計決定を採用しました(承認済み・案1)。
Execute の authority が、ミントの PermanentDelegate 拡張が保持する値と一致する場合のみ、送信者側の Approved 検証を免除する。
この判定を行うのが authority_is_permanent_delegate です。真実の源(source of truth)は、ミント側の PermanentDelegate 拡張値に一本化します。 Config など別の場所に保持した値とは照合しません。これにより、判定基準が一箇所に定まり、鍵の入れ替え時にも齟齬が生じません。
// programs/transfer_hook/src/lib.rs
// authority が「ミントの PermanentDelegate 拡張が保持する delegate」と一致するかを判定する。
// 真実の源はミント側の拡張値であり、他所に保持した値とは照合しない(承認済み設計)。
// PermanentDelegate 拡張が無い、または delegate 未設定(None)の場合は false。
fn authority_is_permanent_delegate(
mint_account: &AccountInfo,
authority: &Pubkey,
) -> Result<bool, ProgramError> {
let data = mint_account.try_borrow_data()?;
let state = StateWithExtensions::<Token2022Mint>::unpack(&data)
.map_err(|_| ProgramError::from(TransferHookError::IncorrectAccount))?;
// 拡張が無いミントでも安全に false を返す(get_permanent_delegate が None を返す)。
match get_permanent_delegate(&state) {
Some(delegate) => Ok(delegate == *authority),
None => Ok(false),
}
}
比較した代替案と、採用しなかった理由:
- 案A(不採用):送信者検証を常に免除する。 免除範囲が広すぎ、通常の送信者署名による移転でも Suspended 保有者が移転できてしまう恐れがあります。コンプライアンス保証を崩します。
- 案B(不採用):強制回収を Hook 迂回の別経路にする。 Hook を通らない転送経路を作ると、受信者の Approved 検証や監査ログも失われます。7.5.1の原則に反します。
- 案1(採用):免除を「authority がミントの permanent delegate と一致する」場合に厳密に限定する。 免除は送信者側のみ、かつ delegate 経路に限られます。通常の送信者署名による転送では免除されません。
7.5.3 免除の限定:受信者は常に検証・免除は delegate 経路のみ#
採用案の要点は「免除を最小限に絞る」ことです。具体的には次の3点を守ります。
- 受信者(回収先トレジャリ)の Approved 検証は免除しません。 回収先も registry に Approved 登録された口座である必要があります。運用上、発行体の回収用トレジャリーアカウントを Approved 登録します。
- 免除は delegate 経路に限定します。 通常の送信者署名による転送では、送信者側も従来どおり Approved が必要です。この限定が効いていることは、
test_suspended_owner_self_transfer_still_fails(Suspended 保有者本人の署名転送は、宛先が Approved でも失敗する)で実証しています(出典:docs/test-cases-ch9.md4.1)。 - transferring フラグ検証・PDA 正当性検証は、経路によらず維持します。
許可状態の検証部分は、この方針をそのまま表しています。
// programs/transfer_hook/src/lib.rs
// --- 4. 許可状態の検証 ---
// 送信者側:delegate 経路のときのみ免除。通常経路では従来通り Approved を要求する。
if is_permanent_delegate {
// 監査証跡:強制回収経路であることを明示的にログに残す(設計書 7.6 イベント設計)。
// ネイティブプログラムのため、構造化イベントの代わりに program log を用いる。
msg!(
"FORCE_TRANSFER via permanent delegate: source_owner={}, destination_owner={}",
source_owner,
destination_owner
);
} else {
// 通常経路:送信者本人も Approved でなければならない。
assert_investor_approved(source_investor_status)?;
}
// 受信者側:経路によらず常に Approved を要求する(回収先トレジャリも Approved 必須)。
assert_investor_approved(destination_investor_status)?;
7.5.4 社内統制上の位置づけと運用手順#
強制回収は、投資家の承認なしに資産を動かす操作です。技術的に可能であることと、実行してよいことは別です。トークン規格側には権限の濫用を防ぐ仕組みはないため(6.2.2)、発動条件・承認プロセス・鍵の保持を社内統制で担保する必要があります。
- Permanent Delegate 鍵は、マルチシグに保持することを推奨します(第10・11章)。
- 凍結中の口座は Token-2022 が移転自体を拒否します。したがって、凍結済みの口座から回収する場合の運用手順は、「①対象口座を thaw → ②Permanent Delegate で強制回収 → ③口座を再 freeze」です。回収先トレジャリも thaw 済みである必要があります(出典:
docs/test-cases-ch9.md1.3 T-23)。 - delegate 経路の転送は、監査証跡として program log(
FORCE_TRANSFER via permanent delegate)に明示的に記録されます(7.6)。
7.6 エラーハンドリングとイベント設計#
レジストリ側(Anchor) は、#[error_code] でカスタムエラーを定義します。Anchor v1 では、エラーブロックは1プログラムにつき1つに統合します。
// programs/registry/src/error.rs
#[error_code]
pub enum RegistryError {
#[msg("この操作を実行できるのはレジストリ管理者のみです")]
Unauthorized,
#[msg("指定した投資家ステータスの値が不正です")]
InvalidStatus,
}
監査証跡としてのログは、レジストリでは各命令ハンドラで msg! を用います。たとえば状態変更時は、対象投資家と新しい状態を記録します。
// programs/registry/src/instructions/set_investor_status.rs
msg!(
"投資家 {} の状態を {:?} に変更しました",
status.investor,
new_state
);
Hook 側(ネイティブ) は、Anchor の構造化イベント(emit!)を使いません。7.1.1で述べたとおりネイティブ実装のため、監査証跡は program log(msg!)で残します。特に強制回収経路は、7.5.3で示した FORCE_TRANSFER via permanent delegate ログで明示的に記録します。オフチェーン側では、このログを監視・突合して監査台帳に取り込むことを想定します(第8章)。
Hook の許可判定は、spl-transfer-hook-interface の TransferHookError で失敗を返します。たとえば InvestorStatus が Approved でない場合や、追加アカウントが不正な場合は IncorrectAccount を、転送コンテキスト外からの呼び出しは ProgramCalledOutsideOfTransfer を返します。これらの失敗が Token-2022 に伝播し、転送を止めます。
7.7 Transfer Hookの誤解しやすい動作(図)#
Transfer Hook で誤解されやすいのは、「Hook が転送を実行するのか」という点です。実際には、転送を実行するのは Token-2022 本体であり、Hook は検証のために CPI で呼ばれるだけです。そして、Hook が失敗すると転送全体が失敗します。
この関係を図で示します。
sequenceDiagram
participant C as クライアント
participant T as Token-2022
participant H as transfer_hook
participant R as registry(PDA読取)
C->>T: transfer_checked(送信者 or delegate が署名)
Note over T: 送信元・送信先の残高を更新<br/>(転送の実行は Token-2022 が行う)
Note over T: transferring フラグを立てる
T->>H: CPI: Execute(追加アカウント解決済み)
H->>R: InvestorStatus PDA を読取・検証
alt 双方 Approved(delegate 経路は送信者を免除)
H-->>T: Ok(())
Note over T: 転送成立
else いずれか未許可 / 検証失敗
H-->>T: Err(TransferHookError)
Note over T: 失敗が伝播し、転送全体が失敗
end
図の要点は次の2つです。
- Hook は転送を再実行しません。 残高の更新は Token-2022 が行い、Hook はその前後の検証だけを担います。
- Hook の失敗が転送を失敗させます。 Hook が
Errを返すと、Token-2022 の転送命令ごと失敗します。この一方向の依存関係が、移転制限を成立させます。
7.8 本章の自己チェック表#
| 観点 | 結果 |
|---|---|
| スタイル規則違反 | なし(です・ます調、一文概ね80字以内、コードにファイルパス付与、相互参照は番号、価格・投資リターン・マーケティング語彙・法的判断示唆なし) |
[要検証] の残数 |
0 件(SAS節は「本書のリファレンス実装では未実装」と本文に明記し、概念として設計方針のみを記載。断定を避けたためタグは不要) |
| バージョン表との不一致 | なし(crate 群は第1章バージョン表と一致。本文中に「latest」「最新版」の記載なし) |
| コードの出所 | すべて programs/registry/src/** および programs/transfer_hook/src/lib.rs からの転記(整形以外の変更なし)。cargo test 全26テスト成功(第9章) |