瀏覽代碼

transfer-hook: Move interface and example into subfolders (#5640)

Jon Cinque 2 年之前
父節點
當前提交
6e9a399a58

+ 24 - 0
interface/Cargo.toml

@@ -0,0 +1,24 @@
+[package]
+name = "spl-transfer-hook-interface"
+version = "0.3.0"
+description = "Solana Program Library Transfer Hook Interface"
+authors = ["Solana Labs Maintainers <maintainers@solanalabs.com>"]
+repository = "https://github.com/solana-labs/solana-program-library"
+license = "Apache-2.0"
+edition = "2021"
+
+[dependencies]
+arrayref = "0.3.7"
+bytemuck = { version = "1.14.0", features = ["derive"] }
+solana-program = "1.17.2"
+spl-discriminator = { version = "0.1" , path = "../../../libraries/discriminator" }
+spl-program-error = { version = "0.3" , path = "../../../libraries/program-error" }
+spl-tlv-account-resolution = { version = "0.4" , path = "../../../libraries/tlv-account-resolution" }
+spl-type-length-value = { version = "0.3" , path = "../../../libraries/type-length-value" }
+spl-pod = { version = "0.1", path = "../../../libraries/pod" }
+
+[lib]
+crate-type = ["cdylib", "lib"]
+
+[package.metadata.docs.rs]
+targets = ["x86_64-unknown-linux-gnu"]

+ 149 - 0
interface/README.md

@@ -0,0 +1,149 @@
+## Transfer-Hook Interface
+
+### Example program
+
+Here is an example program that only implements the required "execute" instruction,
+assuming that the proper account data is already written to the appropriate 
+program-derived address defined by the interface.
+
+```rust
+use {
+    solana_program::{entrypoint::ProgramResult, program_error::ProgramError},
+    spl_tlv_account_resolution::state::ExtraAccountMetaList,
+    spl_transfer_hook_interface::instruction::{ExecuteInstruction, TransferHookInstruction},
+    spl_type_length_value::state::TlvStateBorrowed,
+};
+pub fn process_instruction(
+    program_id: &Pubkey,
+    accounts: &[AccountInfo],
+    input: &[u8],
+) -> ProgramResult {
+    let instruction = TransferHookInstruction::unpack(input)?;
+    let _amount = match instruction {
+        TransferHookInstruction::Execute { amount } => amount,
+        _ => return Err(ProgramError::InvalidInstructionData),
+    };
+    let account_info_iter = &mut accounts.iter();
+
+    // Pull out the accounts in order, none are validated in this test program
+    let _source_account_info = next_account_info(account_info_iter)?;
+    let mint_info = next_account_info(account_info_iter)?;
+    let _destination_account_info = next_account_info(account_info_iter)?;
+    let _authority_info = next_account_info(account_info_iter)?;
+    let extra_account_metas_info = next_account_info(account_info_iter)?;
+
+    // Only check that the correct pda and account are provided
+    let expected_validation_address = get_extra_account_metas_address(mint_info.key, program_id);
+    if expected_validation_address != *extra_account_metas_info.key {
+        return Err(ProgramError::InvalidSeeds);
+    }
+
+    // Load the extra required accounts from the validation account
+    let data = extra_account_metas_info.try_borrow_data()?;
+
+    // Check the provided accounts against the validation data
+    ExtraAccountMetaList::check_account_infos::<ExecuteInstruction>(
+        accounts,
+        &TransferHookInstruction::Execute { amount }.pack(),
+        program_id,
+        &data,
+    )?;
+
+    Ok(())
+}
+```
+
+### Motivation
+
+Token creators may need more control over how their token is transferred. The
+most prominent use case revolves around NFT royalties. Whenever a token is moved,
+the creator should be entitled to royalties, but due to the design of the current
+token program, it's impossible to stop a transfer at the protocol level.
+
+Current solutions typically resort to perpetually freezing tokens, which requires
+a whole proxy layer to interact with the token. Wallets and marketplaces need
+to be aware of the proxy layer in order to properly use the token.
+
+Worse still, different royalty systems have different proxy layers for using
+their token. All in all, these systems harm composability and make development
+harder.
+
+### Solution
+
+To improve the situation, Token-2022 introduces the concept of the transfer-hook
+interface and extension. A token creator must develop and deploy a program that
+implements the interface and then configure their token mint to use their program.
+
+During transfer, Token-2022 calls into the program with the accounts specified
+at a well-defined program-derived address for that mint and program id. This
+call happens after all other transfer logic, so the accounts reflect the *end*
+state of the transfer.
+
+### How to Use
+
+Developers must implement the `Execute` instruction, and optionally the
+`InitializeExtraAccountMetaList` instruction to write the required additional account
+pubkeys into the program-derived address defined by the mint and program id.
+
+Note: it's technically not required to implement `InitializeExtraAccountMetaList`
+at that instruction descriminator. Your program may implement multiple interfaces,
+so any other instruction in your program can create the account at the program-derived
+address!
+
+When your program stores configurations for extra required accounts in the
+well-defined program-derived address, it's possible to send an instruction -
+such as `Execute` (transfer) - to your program with only accounts required
+for the interface instruction, and all extra required accounts are
+automatically resolved!
+
+### Account Resolution
+
+Implementers of the transfer-hook interface are encouraged to make use of the
+[spl-tlv-account-resolution](https://github.com/solana-labs/solana-program-library/tree/master/libraries/tlv-account-resolution/README.md)
+library to manage the additional required accounts for their transfer hook
+program.
+
+TLV Account Resolution is capable of powering on-chain account resolution
+when an instruction that requires extra accounts is invoked.
+Read more about how account resolution works in the repository's
+[README file](https://github.com/solana-labs/solana-program-library/tree/master/libraries/tlv-account-resolution/README.md).
+
+### An Example
+
+You have created a DAO to govern a community. Your DAO's authority is a
+multisig account, and you want to ensure that any transfer of your token is
+approved by the DAO. You also want to make sure that someone who intends to
+transfer your token has the proper permissions to do so.
+
+Let's assume the DAO multisig has some **fixed address**. And let's assume that
+in order to have the `can_transfer` permission, a user must have this
+**dynamic program-derived address** associated with their wallet via the
+following seeds: `"can_transfer" + <wallet_address>`.
+
+Using the transfer-hook interface, you can store these configurations in the
+well-defined program-derived address for your mint and program id.
+
+When a user attempts to transfer your token, they might provide to Token-2022:
+
+```rust
+[source, mint, destination, owner/delegate]
+```
+
+Token-2022 will then call into your program,
+**resolving the extra required accounts automatically** from your stored
+configurations, to result in the following accounts being provided to your
+program:
+
+```rust
+[source, mint, destination, owner/delegate, dao_authority, can_transfer_pda]
+```
+
+### Utilities
+
+The `spl-transfer-hook-interface` library provides offchain and onchain helpers
+for resolving the additional accounts required. See
+[invoke.rs](https://github.com/solana-labs/solana-program-library/tree/master/token/transfer-hook/interface/src/invoke.rs)
+for usage on-chain, and
+[offchain.rs](https://github.com/solana-labs/solana-program-library/tree/master/token/transfer-hook/interface/src/offchain.rs)
+for fetching the additional required account metas with any async off-chain client
+like `BanksClient` or `RpcClient`.

+ 20 - 0
interface/src/error.rs

@@ -0,0 +1,20 @@
+//! Error types
+
+use spl_program_error::*;
+
+/// Errors that may be returned by the interface.
+#[spl_program_error(hash_error_code_start = 2_110_272_652)]
+pub enum TransferHookError {
+    /// Incorrect account provided
+    #[error("Incorrect account provided")]
+    IncorrectAccount,
+    /// Mint has no mint authority
+    #[error("Mint has no mint authority")]
+    MintHasNoMintAuthority,
+    /// Incorrect mint authority has signed the instruction
+    #[error("Incorrect mint authority has signed the instruction")]
+    IncorrectMintAuthority,
+    /// Program called outside of a token transfer
+    #[error("Program called outside of a token transfer")]
+    ProgramCalledOutsideOfTransfer,
+}

+ 243 - 0
interface/src/instruction.rs

@@ -0,0 +1,243 @@
+//! Instruction types
+
+use {
+    solana_program::{
+        instruction::{AccountMeta, Instruction},
+        program_error::ProgramError,
+        pubkey::Pubkey,
+        system_program,
+    },
+    spl_discriminator::{ArrayDiscriminator, SplDiscriminate},
+    spl_pod::{bytemuck::pod_slice_to_bytes, slice::PodSlice},
+    spl_tlv_account_resolution::account::ExtraAccountMeta,
+    std::convert::TryInto,
+};
+
+/// Instructions supported by the transfer hook interface.
+#[repr(C)]
+#[derive(Clone, Debug, PartialEq)]
+pub enum TransferHookInstruction {
+    /// Runs additional transfer logic.
+    ///
+    /// Accounts expected by this instruction:
+    ///
+    ///   0. `[]` Source account
+    ///   1. `[]` Token mint
+    ///   2. `[]` Destination account
+    ///   3. `[]` Source account's owner/delegate
+    ///   4. `[]` Validation account
+    ///   5..5+M `[]` `M` additional accounts, written in validation account data
+    ///
+    Execute {
+        /// Amount of tokens to transfer
+        amount: u64,
+    },
+    /// Initializes the extra account metas on an account, writing into
+    /// the first open TLV space.
+    ///
+    /// Accounts expected by this instruction:
+    ///
+    ///   0. `[w]` Account with extra account metas
+    ///   1. `[]` Mint
+    ///   2. `[s]` Mint authority
+    ///   3. `[]` System program
+    ///
+    InitializeExtraAccountMetaList {
+        /// List of `ExtraAccountMeta`s to write into the account
+        extra_account_metas: Vec<ExtraAccountMeta>,
+    },
+}
+/// TLV instruction type only used to define the discriminator. The actual data
+/// is entirely managed by `ExtraAccountMetaList`, and it is the only data contained
+/// by this type.
+#[derive(SplDiscriminate)]
+#[discriminator_hash_input("spl-transfer-hook-interface:execute")]
+pub struct ExecuteInstruction;
+
+/// TLV instruction type used to initialize extra account metas
+/// for the transfer hook
+#[derive(SplDiscriminate)]
+#[discriminator_hash_input("spl-transfer-hook-interface:initialize-extra-account-metas")]
+pub struct InitializeExtraAccountMetaListInstruction;
+
+impl TransferHookInstruction {
+    /// Unpacks a byte buffer into a [TransferHookInstruction](enum.TransferHookInstruction.html).
+    pub fn unpack(input: &[u8]) -> Result<Self, ProgramError> {
+        if input.len() < ArrayDiscriminator::LENGTH {
+            return Err(ProgramError::InvalidInstructionData);
+        }
+        let (discriminator, rest) = input.split_at(ArrayDiscriminator::LENGTH);
+        Ok(match discriminator {
+            ExecuteInstruction::SPL_DISCRIMINATOR_SLICE => {
+                let amount = rest
+                    .get(..8)
+                    .and_then(|slice| slice.try_into().ok())
+                    .map(u64::from_le_bytes)
+                    .ok_or(ProgramError::InvalidInstructionData)?;
+                Self::Execute { amount }
+            }
+            InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE => {
+                let pod_slice = PodSlice::<ExtraAccountMeta>::unpack(rest)?;
+                let extra_account_metas = pod_slice.data().to_vec();
+                Self::InitializeExtraAccountMetaList {
+                    extra_account_metas,
+                }
+            }
+            _ => return Err(ProgramError::InvalidInstructionData),
+        })
+    }
+
+    /// Packs a [TokenInstruction](enum.TokenInstruction.html) into a byte buffer.
+    pub fn pack(&self) -> Vec<u8> {
+        let mut buf = vec![];
+        match self {
+            Self::Execute { amount } => {
+                buf.extend_from_slice(ExecuteInstruction::SPL_DISCRIMINATOR_SLICE);
+                buf.extend_from_slice(&amount.to_le_bytes());
+            }
+            Self::InitializeExtraAccountMetaList {
+                extra_account_metas,
+            } => {
+                buf.extend_from_slice(
+                    InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE,
+                );
+                buf.extend_from_slice(&(extra_account_metas.len() as u32).to_le_bytes());
+                buf.extend_from_slice(pod_slice_to_bytes(extra_account_metas));
+            }
+        };
+        buf
+    }
+}
+
+/// Creates an `Execute` instruction, provided all of the additional required
+/// account metas
+#[allow(clippy::too_many_arguments)]
+pub fn execute_with_extra_account_metas(
+    program_id: &Pubkey,
+    source_pubkey: &Pubkey,
+    mint_pubkey: &Pubkey,
+    destination_pubkey: &Pubkey,
+    authority_pubkey: &Pubkey,
+    validate_state_pubkey: &Pubkey,
+    additional_accounts: &[AccountMeta],
+    amount: u64,
+) -> Instruction {
+    let mut instruction = execute(
+        program_id,
+        source_pubkey,
+        mint_pubkey,
+        destination_pubkey,
+        authority_pubkey,
+        validate_state_pubkey,
+        amount,
+    );
+    instruction.accounts.extend_from_slice(additional_accounts);
+    instruction
+}
+
+/// Creates an `Execute` instruction, without the additional accounts
+#[allow(clippy::too_many_arguments)]
+pub fn execute(
+    program_id: &Pubkey,
+    source_pubkey: &Pubkey,
+    mint_pubkey: &Pubkey,
+    destination_pubkey: &Pubkey,
+    authority_pubkey: &Pubkey,
+    validate_state_pubkey: &Pubkey,
+    amount: u64,
+) -> Instruction {
+    let data = TransferHookInstruction::Execute { amount }.pack();
+    let accounts = vec![
+        AccountMeta::new_readonly(*source_pubkey, false),
+        AccountMeta::new_readonly(*mint_pubkey, false),
+        AccountMeta::new_readonly(*destination_pubkey, false),
+        AccountMeta::new_readonly(*authority_pubkey, false),
+        AccountMeta::new_readonly(*validate_state_pubkey, false),
+    ];
+    Instruction {
+        program_id: *program_id,
+        accounts,
+        data,
+    }
+}
+
+/// Creates a `InitializeExtraAccountMetaList` instruction.
+pub fn initialize_extra_account_meta_list(
+    program_id: &Pubkey,
+    extra_account_metas_pubkey: &Pubkey,
+    mint_pubkey: &Pubkey,
+    authority_pubkey: &Pubkey,
+    extra_account_metas: &[ExtraAccountMeta],
+) -> Instruction {
+    let data = TransferHookInstruction::InitializeExtraAccountMetaList {
+        extra_account_metas: extra_account_metas.to_vec(),
+    }
+    .pack();
+
+    let accounts = vec![
+        AccountMeta::new(*extra_account_metas_pubkey, false),
+        AccountMeta::new_readonly(*mint_pubkey, false),
+        AccountMeta::new_readonly(*authority_pubkey, true),
+        AccountMeta::new_readonly(system_program::id(), false),
+    ];
+
+    Instruction {
+        program_id: *program_id,
+        accounts,
+        data,
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use {super::*, crate::NAMESPACE, solana_program::hash, spl_pod::bytemuck::pod_from_bytes};
+
+    #[test]
+    fn validate_packing() {
+        let amount = 111_111_111;
+        let check = TransferHookInstruction::Execute { amount };
+        let packed = check.pack();
+        // Please use ExecuteInstruction::SPL_DISCRIMINATOR in your program, the
+        // following is just for test purposes
+        let preimage = hash::hashv(&[format!("{NAMESPACE}:execute").as_bytes()]);
+        let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH];
+        let mut expect = vec![];
+        expect.extend_from_slice(discriminator.as_ref());
+        expect.extend_from_slice(&amount.to_le_bytes());
+        assert_eq!(packed, expect);
+        let unpacked = TransferHookInstruction::unpack(&expect).unwrap();
+        assert_eq!(unpacked, check);
+    }
+
+    #[test]
+    fn initialize_validation_pubkeys_packing() {
+        let extra_meta_len_bytes = &[
+            1, 0, 0, 0, // `1u32`
+        ];
+        let extra_meta_bytes = &[
+            0, // `AccountMeta`
+            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+            1, 1, 1, // pubkey
+            0, // is_signer
+            0, // is_writable
+        ];
+        let extra_account_metas =
+            vec![*pod_from_bytes::<ExtraAccountMeta>(extra_meta_bytes).unwrap()];
+        let check = TransferHookInstruction::InitializeExtraAccountMetaList {
+            extra_account_metas,
+        };
+        let packed = check.pack();
+        // Please use INITIALIZE_EXTRA_ACCOUNT_METAS_DISCRIMINATOR in your program,
+        // the following is just for test purposes
+        let preimage =
+            hash::hashv(&[format!("{NAMESPACE}:initialize-extra-account-metas").as_bytes()]);
+        let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH];
+        let mut expect = vec![];
+        expect.extend_from_slice(discriminator.as_ref());
+        expect.extend_from_slice(extra_meta_len_bytes);
+        expect.extend_from_slice(extra_meta_bytes);
+        assert_eq!(packed, expect);
+        let unpacked = TransferHookInstruction::unpack(&expect).unwrap();
+        assert_eq!(unpacked, check);
+    }
+}

+ 52 - 0
interface/src/lib.rs

@@ -0,0 +1,52 @@
+//! Crate defining an interface for performing a hook on transfer, where the
+//! token program calls into a separate program with additional accounts after
+//! all other logic, to be sure that a transfer has accomplished all required
+//! preconditions.
+
+#![allow(clippy::arithmetic_side_effects)]
+#![deny(missing_docs)]
+#![cfg_attr(not(test), forbid(unsafe_code))]
+
+pub mod error;
+pub mod instruction;
+pub mod offchain;
+pub mod onchain;
+
+// Export current sdk types for downstream users building with a different sdk version
+pub use solana_program;
+use solana_program::pubkey::Pubkey;
+
+/// Namespace for all programs implementing transfer-hook
+pub const NAMESPACE: &str = "spl-transfer-hook-interface";
+
+/// Seed for the state
+const EXTRA_ACCOUNT_METAS_SEED: &[u8] = b"extra-account-metas";
+
+/// Get the state address PDA
+pub fn get_extra_account_metas_address(mint: &Pubkey, program_id: &Pubkey) -> Pubkey {
+    get_extra_account_metas_address_and_bump_seed(mint, program_id).0
+}
+
+/// Function used by programs implementing the interface, when creating the PDA,
+/// to also get the bump seed
+pub fn get_extra_account_metas_address_and_bump_seed(
+    mint: &Pubkey,
+    program_id: &Pubkey,
+) -> (Pubkey, u8) {
+    Pubkey::find_program_address(&collect_extra_account_metas_seeds(mint), program_id)
+}
+
+/// Function used by programs implementing the interface, when creating the PDA,
+/// to get all of the PDA seeds
+pub fn collect_extra_account_metas_seeds(mint: &Pubkey) -> [&[u8]; 2] {
+    [EXTRA_ACCOUNT_METAS_SEED, mint.as_ref()]
+}
+
+/// Function used by programs implementing the interface, when creating the PDA,
+/// to sign for the PDA
+pub fn collect_extra_account_metas_signer_seeds<'a>(
+    mint: &'a Pubkey,
+    bump_seed: &'a [u8],
+) -> [&'a [u8]; 3] {
+    [EXTRA_ACCOUNT_METAS_SEED, mint.as_ref(), bump_seed]
+}

+ 70 - 0
interface/src/offchain.rs

@@ -0,0 +1,70 @@
+//! Offchain helper for fetching required accounts to build instructions
+
+pub use spl_tlv_account_resolution::state::{AccountDataResult, AccountFetchError};
+use {
+    crate::{get_extra_account_metas_address, instruction::ExecuteInstruction},
+    solana_program::{
+        instruction::{AccountMeta, Instruction},
+        program_error::ProgramError,
+        pubkey::Pubkey,
+    },
+    spl_tlv_account_resolution::state::ExtraAccountMetaList,
+    std::future::Future,
+};
+
+/// Offchain helper to get all additional required account metas for a mint
+///
+/// To be client-agnostic and to avoid pulling in the full solana-sdk, this
+/// simply takes a function that will return its data as `Future<Vec<u8>>` for
+/// the given address. Can be called in the following way:
+///
+/// ```rust,ignore
+/// use futures_util::TryFutureExt;
+/// use solana_client::nonblocking::rpc_client::RpcClient;
+/// use solana_program::pubkey::Pubkey;
+///
+/// let program_id = Pubkey::new_unique();
+/// let mint = Pubkey::new_unique();
+/// let client = RpcClient::new_mock("succeeds".to_string());
+/// let mut account_metas = vec![];
+///
+/// get_extra_account_metas(
+///     &mut account_metas,
+///     |address| self.client.get_account(&address).map_ok(|opt| opt.map(|acc| acc.data)),
+///     &mint,
+///     &program_id,
+/// ).await?;
+/// ```
+pub async fn resolve_extra_account_metas<F, Fut>(
+    instruction: &mut Instruction,
+    fetch_account_data_fn: F,
+    mint: &Pubkey,
+    permissioned_transfer_program_id: &Pubkey,
+) -> Result<(), AccountFetchError>
+where
+    F: Fn(Pubkey) -> Fut,
+    Fut: Future<Output = AccountDataResult>,
+{
+    let validation_address =
+        get_extra_account_metas_address(mint, permissioned_transfer_program_id);
+    let validation_account_data = fetch_account_data_fn(validation_address)
+        .await?
+        .ok_or(ProgramError::InvalidAccountData)?;
+    ExtraAccountMetaList::add_to_instruction::<ExecuteInstruction, _, _>(
+        instruction,
+        fetch_account_data_fn,
+        &validation_account_data,
+    )
+    .await?;
+    // The onchain helpers pull out the required accounts from an opaque
+    // slice by pubkey, so the order doesn't matter here!
+    instruction.accounts.push(AccountMeta::new_readonly(
+        *permissioned_transfer_program_id,
+        false,
+    ));
+    instruction
+        .accounts
+        .push(AccountMeta::new_readonly(validation_address, false));
+
+    Ok(())
+}

+ 93 - 0
interface/src/onchain.rs

@@ -0,0 +1,93 @@
+//! On-chain program invoke helper to perform on-chain `execute` with correct accounts
+
+use {
+    crate::{error::TransferHookError, get_extra_account_metas_address, instruction},
+    solana_program::{
+        account_info::AccountInfo,
+        entrypoint::ProgramResult,
+        instruction::{AccountMeta, Instruction},
+        program::invoke,
+        pubkey::Pubkey,
+    },
+    spl_tlv_account_resolution::state::ExtraAccountMetaList,
+};
+/// Helper to CPI into a transfer-hook program on-chain, looking through the
+/// additional account infos to create the proper instruction
+pub fn invoke_execute<'a>(
+    program_id: &Pubkey,
+    source_info: AccountInfo<'a>,
+    mint_info: AccountInfo<'a>,
+    destination_info: AccountInfo<'a>,
+    authority_info: AccountInfo<'a>,
+    additional_accounts: &[AccountInfo<'a>],
+    amount: u64,
+) -> ProgramResult {
+    let validation_pubkey = get_extra_account_metas_address(mint_info.key, program_id);
+    let validation_info = additional_accounts
+        .iter()
+        .find(|&x| *x.key == validation_pubkey)
+        .ok_or(TransferHookError::IncorrectAccount)?;
+    let mut cpi_instruction = instruction::execute(
+        program_id,
+        source_info.key,
+        mint_info.key,
+        destination_info.key,
+        authority_info.key,
+        &validation_pubkey,
+        amount,
+    );
+
+    let mut cpi_account_infos = vec![
+        source_info,
+        mint_info,
+        destination_info,
+        authority_info,
+        validation_info.clone(),
+    ];
+    ExtraAccountMetaList::add_to_cpi_instruction::<instruction::ExecuteInstruction>(
+        &mut cpi_instruction,
+        &mut cpi_account_infos,
+        &validation_info.try_borrow_data()?,
+        additional_accounts,
+    )?;
+    invoke(&cpi_instruction, &cpi_account_infos)
+}
+
+/// Helper to add accounts required for the transfer-hook program on-chain, looking
+/// through the additional account infos to add the proper accounts
+pub fn add_cpi_accounts_for_execute<'a>(
+    cpi_instruction: &mut Instruction,
+    cpi_account_infos: &mut Vec<AccountInfo<'a>>,
+    mint_pubkey: &Pubkey,
+    program_id: &Pubkey,
+    additional_accounts: &[AccountInfo<'a>],
+) -> ProgramResult {
+    let validation_pubkey = get_extra_account_metas_address(mint_pubkey, program_id);
+    let validation_info = additional_accounts
+        .iter()
+        .find(|&x| *x.key == validation_pubkey)
+        .ok_or(TransferHookError::IncorrectAccount)?;
+
+    let program_info = additional_accounts
+        .iter()
+        .find(|&x| x.key == program_id)
+        .ok_or(TransferHookError::IncorrectAccount)?;
+
+    ExtraAccountMetaList::add_to_cpi_instruction::<instruction::ExecuteInstruction>(
+        cpi_instruction,
+        cpi_account_infos,
+        &validation_info.try_borrow_data()?,
+        additional_accounts,
+    )?;
+    // The onchain helpers pull out the required accounts from an opaque
+    // slice by pubkey, so the order doesn't matter here!
+    cpi_account_infos.push(validation_info.clone());
+    cpi_account_infos.push(program_info.clone());
+    cpi_instruction
+        .accounts
+        .push(AccountMeta::new_readonly(validation_pubkey, false));
+    cpi_instruction
+        .accounts
+        .push(AccountMeta::new_readonly(*program_id, false));
+    Ok(())
+}

+ 30 - 0
program/Cargo.toml

@@ -0,0 +1,30 @@
+[package]
+name = "spl-transfer-hook-example"
+version = "0.3.0"
+description = "Solana Program Library Transfer Hook Example Program"
+authors = ["Solana Labs Maintainers <maintainers@solanalabs.com>"]
+repository = "https://github.com/solana-labs/solana-program-library"
+license = "Apache-2.0"
+edition = "2021"
+
+[features]
+no-entrypoint = []
+test-sbf = []
+
+[dependencies]
+arrayref = "0.3.7"
+solana-program = "1.17.2"
+spl-tlv-account-resolution = { version = "0.4" , path = "../../../libraries/tlv-account-resolution" }
+spl-token-2022 = { version = "0.9",  path = "../../program-2022", features = ["no-entrypoint"] }
+spl-transfer-hook-interface = { version = "0.3" , path = "../interface" }
+spl-type-length-value = { version = "0.3" , path = "../../../libraries/type-length-value" }
+
+[dev-dependencies]
+solana-program-test = "1.17.2"
+solana-sdk = "1.17.2"
+
+[lib]
+crate-type = ["cdylib", "lib"]
+
+[package.metadata.docs.rs]
+targets = ["x86_64-unknown-linux-gnu"]

+ 66 - 0
program/README.md

@@ -0,0 +1,66 @@
+## Transfer-Hook Example
+
+Full example program and tests implementing the `spl-transfer-hook-interface`,
+to be used for testing a program that calls into the `spl-transfer-hook-interface`.
+
+See the
+[SPL Transfer Hook Interface](https://github.com/solana-labs/solana-program-library/tree/master/token/transfer-hook/interface)
+code for more information.
+
+### Example usage of example
+
+When testing your program that uses `spl-transfer-hook-interface`, you can also
+import this crate, and then use it with `solana-program-test`, ie:
+
+```rust
+use {
+    solana_program_test::{processor, ProgramTest},
+    solana_sdk::{account::Account, instruction::AccountMeta},
+    spl_transfer_hook_example::state::example_data,
+    spl_transfer_hook_interface::get_extra_account_metas_address,
+};
+
+#[test]
+fn my_program_test() {
+    let mut program_test = ProgramTest::new(
+        "my_program",
+        my_program_id,
+        processor!(my_program_processor),
+    );
+
+    let transfer_hook_program_id = Pubkey::new_unique();
+    program_test.prefer_bpf(false); // BPF won't work, unless you've built this from scratch!
+    program_test.add_program(
+        "spl_transfer_hook_example",
+        transfer_hook_program_id,
+        processor!(spl_transfer_hook_example::processor::process),
+    );
+
+    let mint = Pubkey::new_unique();
+    let extra_accounts_address = get_extra_account_metas_address(&mint, &transfer_hook_program_id);
+    let account_metas = vec![
+        AccountMeta {
+            pubkey: Pubkey::new_unique(),
+            is_signer: false,
+            is_writable: false,
+        },
+        AccountMeta {
+            pubkey: Pubkey::new_unique(),
+            is_signer: false,
+            is_writable: false,
+        },
+    ];
+    let data = example_data(&account_metas);
+    program_test.add_account(
+        extra_accounts_address,
+        Account {
+            lamports: 1_000_000_000, // a lot, just to be safe
+            data,
+            owner: transfer_hook_program_id,
+            ..Account::default()
+        },
+    );
+
+    // run your test logic!
+}
+```

+ 24 - 0
program/src/entrypoint.rs

@@ -0,0 +1,24 @@
+//! Program entrypoint
+
+use {
+    crate::processor,
+    solana_program::{
+        account_info::AccountInfo, entrypoint, entrypoint::ProgramResult,
+        program_error::PrintProgramError, pubkey::Pubkey,
+    },
+    spl_transfer_hook_interface::error::TransferHookError,
+};
+
+entrypoint!(process_instruction);
+fn process_instruction(
+    program_id: &Pubkey,
+    accounts: &[AccountInfo],
+    instruction_data: &[u8],
+) -> ProgramResult {
+    if let Err(error) = processor::process(program_id, accounts, instruction_data) {
+        // catch the error so we can print it
+        error.print::<TransferHookError>();
+        return Err(error);
+    }
+    Ok(())
+}

+ 18 - 0
program/src/lib.rs

@@ -0,0 +1,18 @@
+//! Crate defining an example program for performing a hook on transfer, where
+//! the token program calls into a separate program with additional accounts
+//! after all other logic, to be sure that a transfer has accomplished all
+//! required preconditions.
+
+#![allow(clippy::arithmetic_side_effects)]
+#![deny(missing_docs)]
+#![cfg_attr(not(test), forbid(unsafe_code))]
+
+pub mod processor;
+pub mod state;
+
+#[cfg(not(feature = "no-entrypoint"))]
+mod entrypoint;
+
+// Export current sdk types for downstream users building with a different sdk
+// version
+pub use solana_program;

+ 151 - 0
program/src/processor.rs

@@ -0,0 +1,151 @@
+//! Program state processor
+
+use {
+    solana_program::{
+        account_info::{next_account_info, AccountInfo},
+        entrypoint::ProgramResult,
+        msg,
+        program::invoke_signed,
+        program_error::ProgramError,
+        pubkey::Pubkey,
+        system_instruction,
+    },
+    spl_tlv_account_resolution::{account::ExtraAccountMeta, state::ExtraAccountMetaList},
+    spl_token_2022::{
+        extension::{
+            transfer_hook::TransferHookAccount, BaseStateWithExtensions, StateWithExtensions,
+        },
+        state::{Account, Mint},
+    },
+    spl_transfer_hook_interface::{
+        collect_extra_account_metas_signer_seeds,
+        error::TransferHookError,
+        get_extra_account_metas_address, get_extra_account_metas_address_and_bump_seed,
+        instruction::{ExecuteInstruction, TransferHookInstruction},
+    },
+};
+
+fn check_token_account_is_transferring(account_info: &AccountInfo) -> Result<(), ProgramError> {
+    let account_data = account_info.try_borrow_data()?;
+    let token_account = StateWithExtensions::<Account>::unpack(&account_data)?;
+    let extension = token_account.get_extension::<TransferHookAccount>()?;
+    if bool::from(extension.transferring) {
+        Ok(())
+    } else {
+        Err(TransferHookError::ProgramCalledOutsideOfTransfer.into())
+    }
+}
+
+/// Processes an [Execute](enum.TransferHookInstruction.html) instruction.
+pub fn process_execute(
+    program_id: &Pubkey,
+    accounts: &[AccountInfo],
+    amount: u64,
+) -> ProgramResult {
+    let account_info_iter = &mut accounts.iter();
+
+    let source_account_info = next_account_info(account_info_iter)?;
+    let mint_info = next_account_info(account_info_iter)?;
+    let destination_account_info = next_account_info(account_info_iter)?;
+    let _authority_info = next_account_info(account_info_iter)?;
+    let extra_account_metas_info = next_account_info(account_info_iter)?;
+
+    // Check that the accounts are properly in "transferring" mode
+    check_token_account_is_transferring(source_account_info)?;
+    check_token_account_is_transferring(destination_account_info)?;
+
+    // For the example program, we just check that the correct pda and validation
+    // pubkeys are provided
+    let expected_validation_address = get_extra_account_metas_address(mint_info.key, program_id);
+    if expected_validation_address != *extra_account_metas_info.key {
+        return Err(ProgramError::InvalidSeeds);
+    }
+
+    let data = extra_account_metas_info.try_borrow_data()?;
+
+    ExtraAccountMetaList::check_account_infos::<ExecuteInstruction>(
+        accounts,
+        &TransferHookInstruction::Execute { amount }.pack(),
+        program_id,
+        &data,
+    )?;
+
+    Ok(())
+}
+
+/// Processes a [InitializeExtraAccountMetaList](enum.TransferHookInstruction.html) instruction.
+pub fn process_initialize_extra_account_meta_list(
+    program_id: &Pubkey,
+    accounts: &[AccountInfo],
+    extra_account_metas: &[ExtraAccountMeta],
+) -> ProgramResult {
+    let account_info_iter = &mut accounts.iter();
+
+    let extra_account_metas_info = next_account_info(account_info_iter)?;
+    let mint_info = next_account_info(account_info_iter)?;
+    let authority_info = next_account_info(account_info_iter)?;
+    let _system_program_info = next_account_info(account_info_iter)?;
+
+    // check that the mint authority is valid without fully deserializing
+    let mint_data = mint_info.try_borrow_data()?;
+    let mint = StateWithExtensions::<Mint>::unpack(&mint_data)?;
+    let mint_authority = mint
+        .base
+        .mint_authority
+        .ok_or(TransferHookError::MintHasNoMintAuthority)?;
+
+    // Check signers
+    if !authority_info.is_signer {
+        return Err(ProgramError::MissingRequiredSignature);
+    }
+    if *authority_info.key != mint_authority {
+        return Err(TransferHookError::IncorrectMintAuthority.into());
+    }
+
+    // Check validation account
+    let (expected_validation_address, bump_seed) =
+        get_extra_account_metas_address_and_bump_seed(mint_info.key, program_id);
+    if expected_validation_address != *extra_account_metas_info.key {
+        return Err(ProgramError::InvalidSeeds);
+    }
+
+    // Create the account
+    let bump_seed = [bump_seed];
+    let signer_seeds = collect_extra_account_metas_signer_seeds(mint_info.key, &bump_seed);
+    let length = extra_account_metas.len();
+    let account_size = ExtraAccountMetaList::size_of(length)?;
+    invoke_signed(
+        &system_instruction::allocate(extra_account_metas_info.key, account_size as u64),
+        &[extra_account_metas_info.clone()],
+        &[&signer_seeds],
+    )?;
+    invoke_signed(
+        &system_instruction::assign(extra_account_metas_info.key, program_id),
+        &[extra_account_metas_info.clone()],
+        &[&signer_seeds],
+    )?;
+
+    // Write the data
+    let mut data = extra_account_metas_info.try_borrow_mut_data()?;
+    ExtraAccountMetaList::init::<ExecuteInstruction>(&mut data, extra_account_metas)?;
+
+    Ok(())
+}
+
+/// Processes an [Instruction](enum.Instruction.html).
+pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult {
+    let instruction = TransferHookInstruction::unpack(input)?;
+
+    match instruction {
+        TransferHookInstruction::Execute { amount } => {
+            msg!("Instruction: Execute");
+            process_execute(program_id, accounts, amount)
+        }
+        TransferHookInstruction::InitializeExtraAccountMetaList {
+            extra_account_metas,
+        } => {
+            msg!("Instruction: InitializeExtraAccountMetaList");
+            process_initialize_extra_account_meta_list(program_id, accounts, &extra_account_metas)
+        }
+    }
+}

+ 15 - 0
program/src/state.rs

@@ -0,0 +1,15 @@
+//! State helpers for working with the example program
+
+use {
+    solana_program::program_error::ProgramError,
+    spl_tlv_account_resolution::{account::ExtraAccountMeta, state::ExtraAccountMetaList},
+    spl_transfer_hook_interface::instruction::ExecuteInstruction,
+};
+
+/// Generate example data to be used directly in an account for testing
+pub fn example_data(account_metas: &[ExtraAccountMeta]) -> Result<Vec<u8>, ProgramError> {
+    let account_size = ExtraAccountMetaList::size_of(account_metas.len())?;
+    let mut data = vec![0; account_size];
+    ExtraAccountMetaList::init::<ExecuteInstruction>(&mut data, account_metas)?;
+    Ok(data)
+}

+ 749 - 0
program/tests/functional.rs

@@ -0,0 +1,749 @@
+// Mark this test as SBF-only due to current `ProgramTest` limitations when
+// CPIing into the system program
+#![cfg(feature = "test-sbf")]
+
+use {
+    solana_program_test::{processor, tokio, ProgramTest},
+    solana_sdk::{
+        account::Account as SolanaAccount,
+        account_info::AccountInfo,
+        entrypoint::ProgramResult,
+        instruction::{AccountMeta, InstructionError},
+        program_error::ProgramError,
+        program_option::COption,
+        pubkey::Pubkey,
+        signature::Signer,
+        signer::keypair::Keypair,
+        system_instruction, sysvar,
+        transaction::{Transaction, TransactionError},
+    },
+    spl_tlv_account_resolution::{
+        account::ExtraAccountMeta, error::AccountResolutionError, seeds::Seed,
+        state::ExtraAccountMetaList,
+    },
+    spl_token_2022::{
+        extension::{transfer_hook::TransferHookAccount, ExtensionType, StateWithExtensionsMut},
+        state::{Account, AccountState, Mint},
+    },
+    spl_transfer_hook_interface::{
+        error::TransferHookError,
+        get_extra_account_metas_address,
+        instruction::{execute_with_extra_account_metas, initialize_extra_account_meta_list},
+        onchain,
+    },
+};
+
+fn setup(program_id: &Pubkey) -> ProgramTest {
+    let mut program_test = ProgramTest::new(
+        "spl_transfer_hook_example",
+        *program_id,
+        processor!(spl_transfer_hook_example::processor::process),
+    );
+
+    program_test.prefer_bpf(false); // simplicity in the build
+
+    program_test.add_program(
+        "spl_token_2022",
+        spl_token_2022::id(),
+        processor!(spl_token_2022::processor::Processor::process),
+    );
+
+    program_test
+}
+
+#[allow(clippy::too_many_arguments)]
+fn setup_token_accounts(
+    program_test: &mut ProgramTest,
+    program_id: &Pubkey,
+    mint_address: &Pubkey,
+    mint_authority: &Pubkey,
+    source: &Pubkey,
+    destination: &Pubkey,
+    owner: &Pubkey,
+    decimals: u8,
+    transferring: bool,
+) {
+    // add mint, source, and destination accounts by hand to always force
+    // the "transferring" flag to true
+    let mint_size = ExtensionType::try_calculate_account_len::<Mint>(&[]).unwrap();
+    let mut mint_data = vec![0; mint_size];
+    let mut state = StateWithExtensionsMut::<Mint>::unpack_uninitialized(&mut mint_data).unwrap();
+    let token_amount = 1_000_000_000_000;
+    state.base = Mint {
+        mint_authority: COption::Some(*mint_authority),
+        supply: token_amount,
+        decimals,
+        is_initialized: true,
+        freeze_authority: COption::None,
+    };
+    state.pack_base();
+    program_test.add_account(
+        *mint_address,
+        SolanaAccount {
+            lamports: 1_000_000_000,
+            data: mint_data,
+            owner: *program_id,
+            ..SolanaAccount::default()
+        },
+    );
+
+    let account_size =
+        ExtensionType::try_calculate_account_len::<Account>(&[ExtensionType::TransferHookAccount])
+            .unwrap();
+    let mut account_data = vec![0; account_size];
+    let mut state =
+        StateWithExtensionsMut::<Account>::unpack_uninitialized(&mut account_data).unwrap();
+    let extension = state.init_extension::<TransferHookAccount>(true).unwrap();
+    extension.transferring = transferring.into();
+    let token_amount = 1_000_000_000_000;
+    state.base = Account {
+        mint: *mint_address,
+        owner: *owner,
+        amount: token_amount,
+        delegate: COption::None,
+        state: AccountState::Initialized,
+        is_native: COption::None,
+        delegated_amount: 0,
+        close_authority: COption::None,
+    };
+    state.pack_base();
+    state.init_account_type().unwrap();
+
+    program_test.add_account(
+        *source,
+        SolanaAccount {
+            lamports: 1_000_000_000,
+            data: account_data.clone(),
+            owner: *program_id,
+            ..SolanaAccount::default()
+        },
+    );
+    program_test.add_account(
+        *destination,
+        SolanaAccount {
+            lamports: 1_000_000_000,
+            data: account_data,
+            owner: *program_id,
+            ..SolanaAccount::default()
+        },
+    );
+}
+
+#[tokio::test]
+async fn success_execute() {
+    let program_id = Pubkey::new_unique();
+    let mut program_test = setup(&program_id);
+
+    let token_program_id = spl_token_2022::id();
+    let wallet = Keypair::new();
+    let mint_address = Pubkey::new_unique();
+    let mint_authority = Keypair::new();
+    let mint_authority_pubkey = mint_authority.pubkey();
+    let source = Pubkey::new_unique();
+    let destination = Pubkey::new_unique();
+    let decimals = 2;
+    let amount = 0u64;
+
+    setup_token_accounts(
+        &mut program_test,
+        &token_program_id,
+        &mint_address,
+        &mint_authority_pubkey,
+        &source,
+        &destination,
+        &wallet.pubkey(),
+        decimals,
+        true,
+    );
+
+    let extra_account_metas_address = get_extra_account_metas_address(&mint_address, &program_id);
+
+    let writable_pubkey = Pubkey::new_unique();
+
+    let init_extra_account_metas = [
+        ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(),
+        ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(),
+        ExtraAccountMeta::new_with_seeds(
+            &[
+                Seed::Literal {
+                    bytes: b"seed-prefix".to_vec(),
+                },
+                Seed::AccountKey { index: 0 },
+            ],
+            false,
+            true,
+        )
+        .unwrap(),
+        ExtraAccountMeta::new_with_seeds(
+            &[
+                Seed::InstructionData {
+                    index: 8,  // After instruction discriminator
+                    length: 8, // `u64` (amount)
+                },
+                Seed::AccountKey { index: 2 },
+            ],
+            false,
+            true,
+        )
+        .unwrap(),
+        ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(),
+    ];
+
+    let extra_pda_1 = Pubkey::find_program_address(
+        &[
+            b"seed-prefix",  // Literal prefix
+            source.as_ref(), // Account at index 0
+        ],
+        &program_id,
+    )
+    .0;
+    let extra_pda_2 = Pubkey::find_program_address(
+        &[
+            &amount.to_le_bytes(), // Instruction data bytes 8 to 16
+            destination.as_ref(),  // Account at index 2
+        ],
+        &program_id,
+    )
+    .0;
+
+    let extra_account_metas = [
+        AccountMeta::new_readonly(sysvar::instructions::id(), false),
+        AccountMeta::new_readonly(mint_authority_pubkey, true),
+        AccountMeta::new(extra_pda_1, false),
+        AccountMeta::new(extra_pda_2, false),
+        AccountMeta::new(writable_pubkey, false),
+    ];
+
+    let mut context = program_test.start_with_context().await;
+    let rent = context.banks_client.get_rent().await.unwrap();
+    let rent_lamports = rent
+        .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap());
+    let transaction = Transaction::new_signed_with_payer(
+        &[
+            system_instruction::transfer(
+                &context.payer.pubkey(),
+                &extra_account_metas_address,
+                rent_lamports,
+            ),
+            initialize_extra_account_meta_list(
+                &program_id,
+                &extra_account_metas_address,
+                &mint_address,
+                &mint_authority_pubkey,
+                &init_extra_account_metas,
+            ),
+        ],
+        Some(&context.payer.pubkey()),
+        &[&context.payer, &mint_authority],
+        context.last_blockhash,
+    );
+
+    context
+        .banks_client
+        .process_transaction(transaction)
+        .await
+        .unwrap();
+
+    // fail with missing account
+    {
+        let transaction = Transaction::new_signed_with_payer(
+            &[execute_with_extra_account_metas(
+                &program_id,
+                &source,
+                &mint_address,
+                &destination,
+                &wallet.pubkey(),
+                &extra_account_metas_address,
+                &extra_account_metas[..2],
+                amount,
+            )],
+            Some(&context.payer.pubkey()),
+            &[&context.payer, &mint_authority],
+            context.last_blockhash,
+        );
+        let error = context
+            .banks_client
+            .process_transaction(transaction)
+            .await
+            .unwrap_err()
+            .unwrap();
+        assert_eq!(
+            error,
+            TransactionError::InstructionError(
+                0,
+                InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32),
+            )
+        );
+    }
+
+    // fail with wrong account
+    {
+        let extra_account_metas = [
+            AccountMeta::new_readonly(sysvar::instructions::id(), false),
+            AccountMeta::new_readonly(mint_authority_pubkey, true),
+            AccountMeta::new(extra_pda_1, false),
+            AccountMeta::new(extra_pda_2, false),
+            AccountMeta::new(Pubkey::new_unique(), false),
+        ];
+        let transaction = Transaction::new_signed_with_payer(
+            &[execute_with_extra_account_metas(
+                &program_id,
+                &source,
+                &mint_address,
+                &destination,
+                &wallet.pubkey(),
+                &extra_account_metas_address,
+                &extra_account_metas,
+                amount,
+            )],
+            Some(&context.payer.pubkey()),
+            &[&context.payer, &mint_authority],
+            context.last_blockhash,
+        );
+        let error = context
+            .banks_client
+            .process_transaction(transaction)
+            .await
+            .unwrap_err()
+            .unwrap();
+        assert_eq!(
+            error,
+            TransactionError::InstructionError(
+                0,
+                InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32),
+            )
+        );
+    }
+
+    // fail with wrong PDA
+    let wrong_pda_2 = Pubkey::find_program_address(
+        &[
+            &99u64.to_le_bytes(), // Wrong data
+            destination.as_ref(),
+        ],
+        &program_id,
+    )
+    .0;
+    {
+        let extra_account_metas = [
+            AccountMeta::new_readonly(sysvar::instructions::id(), false),
+            AccountMeta::new_readonly(mint_authority_pubkey, true),
+            AccountMeta::new(extra_pda_1, false),
+            AccountMeta::new(wrong_pda_2, false),
+            AccountMeta::new(writable_pubkey, false),
+        ];
+        let transaction = Transaction::new_signed_with_payer(
+            &[execute_with_extra_account_metas(
+                &program_id,
+                &source,
+                &mint_address,
+                &destination,
+                &wallet.pubkey(),
+                &extra_account_metas_address,
+                &extra_account_metas,
+                amount,
+            )],
+            Some(&context.payer.pubkey()),
+            &[&context.payer, &mint_authority],
+            context.last_blockhash,
+        );
+        let error = context
+            .banks_client
+            .process_transaction(transaction)
+            .await
+            .unwrap_err()
+            .unwrap();
+        assert_eq!(
+            error,
+            TransactionError::InstructionError(
+                0,
+                InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32),
+            )
+        );
+    }
+
+    // fail with not signer
+    {
+        let extra_account_metas = [
+            AccountMeta::new_readonly(sysvar::instructions::id(), false),
+            AccountMeta::new_readonly(mint_authority_pubkey, false),
+            AccountMeta::new(extra_pda_1, false),
+            AccountMeta::new(extra_pda_2, false),
+            AccountMeta::new(writable_pubkey, false),
+        ];
+        let transaction = Transaction::new_signed_with_payer(
+            &[execute_with_extra_account_metas(
+                &program_id,
+                &source,
+                &mint_address,
+                &destination,
+                &wallet.pubkey(),
+                &extra_account_metas_address,
+                &extra_account_metas,
+                amount,
+            )],
+            Some(&context.payer.pubkey()),
+            &[&context.payer],
+            context.last_blockhash,
+        );
+        let error = context
+            .banks_client
+            .process_transaction(transaction)
+            .await
+            .unwrap_err()
+            .unwrap();
+        assert_eq!(
+            error,
+            TransactionError::InstructionError(
+                0,
+                InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32),
+            )
+        );
+    }
+
+    // success with correct params
+    {
+        let transaction = Transaction::new_signed_with_payer(
+            &[execute_with_extra_account_metas(
+                &program_id,
+                &source,
+                &mint_address,
+                &destination,
+                &wallet.pubkey(),
+                &extra_account_metas_address,
+                &extra_account_metas,
+                amount,
+            )],
+            Some(&context.payer.pubkey()),
+            &[&context.payer, &mint_authority],
+            context.last_blockhash,
+        );
+        context
+            .banks_client
+            .process_transaction(transaction)
+            .await
+            .unwrap();
+    }
+}
+
+#[tokio::test]
+async fn fail_incorrect_derivation() {
+    let program_id = Pubkey::new_unique();
+    let mut program_test = setup(&program_id);
+
+    let token_program_id = spl_token_2022::id();
+    let wallet = Keypair::new();
+    let mint_address = Pubkey::new_unique();
+    let mint_authority = Keypair::new();
+    let mint_authority_pubkey = mint_authority.pubkey();
+    let source = Pubkey::new_unique();
+    let destination = Pubkey::new_unique();
+    let decimals = 2;
+    setup_token_accounts(
+        &mut program_test,
+        &token_program_id,
+        &mint_address,
+        &mint_authority_pubkey,
+        &source,
+        &destination,
+        &wallet.pubkey(),
+        decimals,
+        true,
+    );
+
+    // wrong derivation
+    let extra_account_metas = get_extra_account_metas_address(&program_id, &mint_address);
+
+    let mut context = program_test.start_with_context().await;
+    let rent = context.banks_client.get_rent().await.unwrap();
+    let rent_lamports = rent.minimum_balance(ExtraAccountMetaList::size_of(0).unwrap());
+
+    let transaction = Transaction::new_signed_with_payer(
+        &[
+            system_instruction::transfer(
+                &context.payer.pubkey(),
+                &extra_account_metas,
+                rent_lamports,
+            ),
+            initialize_extra_account_meta_list(
+                &program_id,
+                &extra_account_metas,
+                &mint_address,
+                &mint_authority_pubkey,
+                &[],
+            ),
+        ],
+        Some(&context.payer.pubkey()),
+        &[&context.payer, &mint_authority],
+        context.last_blockhash,
+    );
+    let error = context
+        .banks_client
+        .process_transaction(transaction)
+        .await
+        .unwrap_err()
+        .unwrap();
+    assert_eq!(
+        error,
+        TransactionError::InstructionError(1, InstructionError::InvalidSeeds)
+    );
+}
+
+/// Test program to CPI into default transfer-hook-interface program
+pub fn process_instruction(
+    _program_id: &Pubkey,
+    accounts: &[AccountInfo],
+    input: &[u8],
+) -> ProgramResult {
+    let amount = input
+        .get(8..16)
+        .and_then(|slice| slice.try_into().ok())
+        .map(u64::from_le_bytes)
+        .ok_or(ProgramError::InvalidInstructionData)?;
+    onchain::invoke_execute(
+        accounts[0].key,
+        accounts[1].clone(),
+        accounts[2].clone(),
+        accounts[3].clone(),
+        accounts[4].clone(),
+        &accounts[5..],
+        amount,
+    )
+}
+
+#[tokio::test]
+async fn success_on_chain_invoke() {
+    let hook_program_id = Pubkey::new_unique();
+    let mut program_test = setup(&hook_program_id);
+    let program_id = Pubkey::new_unique();
+    program_test.add_program(
+        "test_cpi_program",
+        program_id,
+        processor!(process_instruction),
+    );
+
+    let token_program_id = spl_token_2022::id();
+    let wallet = Keypair::new();
+    let mint_address = Pubkey::new_unique();
+    let mint_authority = Keypair::new();
+    let mint_authority_pubkey = mint_authority.pubkey();
+    let source = Pubkey::new_unique();
+    let destination = Pubkey::new_unique();
+    let decimals = 2;
+    let amount = 0u64;
+
+    setup_token_accounts(
+        &mut program_test,
+        &token_program_id,
+        &mint_address,
+        &mint_authority_pubkey,
+        &source,
+        &destination,
+        &wallet.pubkey(),
+        decimals,
+        true,
+    );
+
+    let extra_account_metas_address =
+        get_extra_account_metas_address(&mint_address, &hook_program_id);
+    let writable_pubkey = Pubkey::new_unique();
+
+    let init_extra_account_metas = [
+        ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(),
+        ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(),
+        ExtraAccountMeta::new_with_seeds(
+            &[
+                Seed::Literal {
+                    bytes: b"seed-prefix".to_vec(),
+                },
+                Seed::AccountKey { index: 0 },
+            ],
+            false,
+            true,
+        )
+        .unwrap(),
+        ExtraAccountMeta::new_with_seeds(
+            &[
+                Seed::InstructionData {
+                    index: 8,  // After instruction discriminator
+                    length: 8, // `u64` (amount)
+                },
+                Seed::AccountKey { index: 2 },
+            ],
+            false,
+            true,
+        )
+        .unwrap(),
+        ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(),
+    ];
+
+    let extra_pda_1 = Pubkey::find_program_address(
+        &[
+            b"seed-prefix",  // Literal prefix
+            source.as_ref(), // Account at index 0
+        ],
+        &hook_program_id,
+    )
+    .0;
+    let extra_pda_2 = Pubkey::find_program_address(
+        &[
+            &amount.to_le_bytes(), // Instruction data bytes 8 to 16
+            destination.as_ref(),  // Account at index 2
+        ],
+        &hook_program_id,
+    )
+    .0;
+
+    let extra_account_metas = [
+        AccountMeta::new_readonly(sysvar::instructions::id(), false),
+        AccountMeta::new_readonly(mint_authority_pubkey, true),
+        AccountMeta::new(extra_pda_1, false),
+        AccountMeta::new(extra_pda_2, false),
+        AccountMeta::new(writable_pubkey, false),
+    ];
+
+    let mut context = program_test.start_with_context().await;
+    let rent = context.banks_client.get_rent().await.unwrap();
+    let rent_lamports = rent
+        .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap());
+    let transaction = Transaction::new_signed_with_payer(
+        &[
+            system_instruction::transfer(
+                &context.payer.pubkey(),
+                &extra_account_metas_address,
+                rent_lamports,
+            ),
+            initialize_extra_account_meta_list(
+                &hook_program_id,
+                &extra_account_metas_address,
+                &mint_address,
+                &mint_authority_pubkey,
+                &init_extra_account_metas,
+            ),
+        ],
+        Some(&context.payer.pubkey()),
+        &[&context.payer, &mint_authority],
+        context.last_blockhash,
+    );
+
+    context
+        .banks_client
+        .process_transaction(transaction)
+        .await
+        .unwrap();
+
+    // easier to hack this up!
+    let mut test_instruction = execute_with_extra_account_metas(
+        &program_id,
+        &source,
+        &mint_address,
+        &destination,
+        &wallet.pubkey(),
+        &extra_account_metas_address,
+        &extra_account_metas,
+        amount,
+    );
+    test_instruction
+        .accounts
+        .insert(0, AccountMeta::new_readonly(hook_program_id, false));
+    let transaction = Transaction::new_signed_with_payer(
+        &[test_instruction],
+        Some(&context.payer.pubkey()),
+        &[&context.payer, &mint_authority],
+        context.last_blockhash,
+    );
+
+    context
+        .banks_client
+        .process_transaction(transaction)
+        .await
+        .unwrap();
+}
+
+#[tokio::test]
+async fn fail_without_transferring_flag() {
+    let program_id = Pubkey::new_unique();
+    let mut program_test = setup(&program_id);
+
+    let token_program_id = spl_token_2022::id();
+    let wallet = Keypair::new();
+    let mint_address = Pubkey::new_unique();
+    let mint_authority = Keypair::new();
+    let mint_authority_pubkey = mint_authority.pubkey();
+    let source = Pubkey::new_unique();
+    let destination = Pubkey::new_unique();
+    let decimals = 2;
+
+    setup_token_accounts(
+        &mut program_test,
+        &token_program_id,
+        &mint_address,
+        &mint_authority_pubkey,
+        &source,
+        &destination,
+        &wallet.pubkey(),
+        decimals,
+        false,
+    );
+
+    let extra_account_metas_address = get_extra_account_metas_address(&mint_address, &program_id);
+    let extra_account_metas = [];
+    let init_extra_account_metas = [];
+    let mut context = program_test.start_with_context().await;
+    let rent = context.banks_client.get_rent().await.unwrap();
+    let rent_lamports = rent
+        .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap());
+    let transaction = Transaction::new_signed_with_payer(
+        &[
+            system_instruction::transfer(
+                &context.payer.pubkey(),
+                &extra_account_metas_address,
+                rent_lamports,
+            ),
+            initialize_extra_account_meta_list(
+                &program_id,
+                &extra_account_metas_address,
+                &mint_address,
+                &mint_authority_pubkey,
+                &init_extra_account_metas,
+            ),
+        ],
+        Some(&context.payer.pubkey()),
+        &[&context.payer, &mint_authority],
+        context.last_blockhash,
+    );
+
+    context
+        .banks_client
+        .process_transaction(transaction)
+        .await
+        .unwrap();
+    let transaction = Transaction::new_signed_with_payer(
+        &[execute_with_extra_account_metas(
+            &program_id,
+            &source,
+            &mint_address,
+            &destination,
+            &wallet.pubkey(),
+            &extra_account_metas_address,
+            &extra_account_metas,
+            0,
+        )],
+        Some(&context.payer.pubkey()),
+        &[&context.payer],
+        context.last_blockhash,
+    );
+    let error = context
+        .banks_client
+        .process_transaction(transaction)
+        .await
+        .unwrap_err()
+        .unwrap();
+    assert_eq!(
+        error,
+        TransactionError::InstructionError(
+            0,
+            InstructionError::Custom(TransferHookError::ProgramCalledOutsideOfTransfer as u32)
+        )
+    );
+}