コンテンツにスキップ

第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 の口座定義がその典型です。configadmin フィールドと署名者 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_statusseedsinvestor_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段構えです。

  1. 「転送中」フラグの検証: トークンアカウントの 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(())
    }
    
  2. 追加アカウントの正当性検証: registry プログラムのアドレス一致と、InvestorStatus PDA の所有者・シード検証を行います(7.3.2)。

  3. 許可状態の検証: 送信者・受信者双方の InvestorStatusApproved であることを検証します。ただし送信者側は、強制回収経路のときのみ免除します(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.md 4.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.md 1.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-interfaceTransferHookError で失敗を返します。たとえば 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章)