transfer-hook-interface: Add interface defining a hook called during token-2022 transfer (#4147)

This commit is contained in:
Jon Cinque 2023-05-02 22:21:42 +02:00 committed by GitHub
parent de05760e78
commit d92664f6df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1449 additions and 5 deletions

View File

@ -64,6 +64,9 @@ jobs:
- name: Build and test ATA - name: Build and test ATA
run: ./ci/cargo-test-sbf.sh associated-token-account run: ./ci/cargo-test-sbf.sh associated-token-account
- name: Build and test transfer hook example
run: ./ci/cargo-test-sbf.sh token/transfer-hook-example
- name: Build and test token-2022 with "serde" activated - name: Build and test token-2022 with "serde" activated
run: | run: |
cargo +"${{ env.RUST_STABLE }}" test \ cargo +"${{ env.RUST_STABLE }}" test \

31
Cargo.lock generated
View File

@ -6618,6 +6618,37 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "spl-transfer-hook-example"
version = "0.1.0"
dependencies = [
"arrayref",
"solana-program",
"solana-program-test",
"solana-sdk",
"spl-tlv-account-resolution",
"spl-token-2022 0.6.1",
"spl-token-client",
"spl-transfer-hook-interface",
"spl-type-length-value",
]
[[package]]
name = "spl-transfer-hook-interface"
version = "0.1.0"
dependencies = [
"arrayref",
"bytemuck",
"num-derive",
"num-traits",
"num_enum",
"solana-program",
"solana-sdk",
"spl-tlv-account-resolution",
"spl-type-length-value",
"thiserror",
]
[[package]] [[package]]
name = "spl-type-length-value" name = "spl-type-length-value"
version = "0.1.0" version = "0.1.0"

View File

@ -46,6 +46,8 @@ members = [
"token/program", "token/program",
"token/program-2022", "token/program-2022",
"token/program-2022-test", "token/program-2022-test",
"token/transfer-hook-example",
"token/transfer-hook-interface",
"token/client", "token/client",
"utils/cgen", "utils/cgen",
"utils/test-client", "utils/test-client",

View File

@ -102,6 +102,18 @@ impl ExtraAccountMetas {
Self::init::<T, AccountInfo>(data, account_infos) Self::init::<T, AccountInfo>(data, account_infos)
} }
/// Get the underlying `PodSlice<PodAccountMeta>` from an unpacked TLV
///
/// Due to lifetime annoyances, this function can't just take in the bytes,
/// since then we would be returning a reference to a locally created
/// `TlvStateBorrowed`. I hope there's a better way to do this!
pub fn unpack_with_tlv_state<'a, T: TlvDiscriminator>(
tlv_state: &'a TlvStateBorrowed,
) -> Result<PodSlice<'a, PodAccountMeta>, ProgramError> {
let bytes = tlv_state.get_bytes::<T>()?;
PodSlice::<PodAccountMeta>::unpack(bytes)
}
/// Initialize a TLV entry for the given discriminator, populating the data /// Initialize a TLV entry for the given discriminator, populating the data
/// with the given account metas /// with the given account metas
pub fn init_with_account_metas<T: TlvDiscriminator>( pub fn init_with_account_metas<T: TlvDiscriminator>(
@ -118,19 +130,25 @@ impl ExtraAccountMetas {
} }
/// Add the additional account metas to an existing instruction /// Add the additional account metas to an existing instruction
pub fn add_to_instruction<T: TlvDiscriminator>( pub fn add_to_vec<T: TlvDiscriminator>(
instruction: &mut Instruction, account_metas: &mut Vec<AccountMeta>,
data: &[u8], data: &[u8],
) -> Result<(), ProgramError> { ) -> Result<(), ProgramError> {
let state = TlvStateBorrowed::unpack(data)?; let state = TlvStateBorrowed::unpack(data)?;
let bytes = state.get_bytes::<T>()?; let bytes = state.get_bytes::<T>()?;
let extra_account_metas = PodSlice::<PodAccountMeta>::unpack(bytes)?; let extra_account_metas = PodSlice::<PodAccountMeta>::unpack(bytes)?;
instruction account_metas.extend(extra_account_metas.data().iter().map(|m| m.into()));
.accounts
.extend(extra_account_metas.data().iter().map(|m| m.into()));
Ok(()) Ok(())
} }
/// Add the additional account metas to an existing instruction
pub fn add_to_instruction<T: TlvDiscriminator>(
instruction: &mut Instruction,
data: &[u8],
) -> Result<(), ProgramError> {
Self::add_to_vec::<T>(&mut instruction.accounts, data)
}
/// Add the additional account metas and account infos for a CPI /// Add the additional account metas and account infos for a CPI
pub fn add_to_cpi_instruction<'a, T: TlvDiscriminator>( pub fn add_to_cpi_instruction<'a, T: TlvDiscriminator>(
cpi_instruction: &mut Instruction, cpi_instruction: &mut Instruction,

View File

@ -34,6 +34,11 @@ impl AsRef<[u8]> for Discriminator {
&self.0[..] &self.0[..]
} }
} }
impl AsRef<[u8; Discriminator::LENGTH]> for Discriminator {
fn as_ref(&self) -> &[u8; Discriminator::LENGTH] {
&self.0
}
}
impl From<u64> for Discriminator { impl From<u64> for Discriminator {
fn from(from: u64) -> Self { fn from(from: u64) -> Self {
Self(from.to_le_bytes()) Self(from.to_le_bytes())

View File

@ -0,0 +1,31 @@
[package]
name = "spl-transfer-hook-example"
version = "0.1.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.6"
solana-program = "1.14.12"
spl-tlv-account-resolution = { version = "0.1.0" , path = "../../libraries/tlv-account-resolution" }
spl-transfer-hook-interface = { version = "0.1.0" , path = "../transfer-hook-interface" }
spl-type-length-value = { version = "0.1.0" , path = "../../libraries/type-length-value" }
[dev-dependencies]
solana-program-test = "1.14.12"
solana-sdk = "1.14.12"
spl-token-2022 = { version = "0.6", path = "../program-2022", features = ["no-entrypoint"] }
spl-token-client = { version = "0.5", path = "../client" }
[lib]
crate-type = ["cdylib", "lib"]
[package.metadata.docs.rs]
targets = ["x86_64-unknown-linux-gnu"]

View File

@ -0,0 +1,54 @@
## 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,
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 data = example_data();
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!
}
```

View File

@ -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(())
}

View File

@ -0,0 +1,29 @@
//! Structs required to verify spl-token-2022 mints.
//!
//! By copying the required functions here, we avoid a circular dependency
//! between spl-token-2022 and this crate.
use {
arrayref::{array_ref, array_refs},
solana_program::{program_error::ProgramError, program_option::COption, pubkey::Pubkey},
};
fn unpack_coption_key(src: &[u8; 36]) -> Result<COption<Pubkey>, ProgramError> {
let (tag, body) = array_refs![src, 4, 32];
match *tag {
[0, 0, 0, 0] => Ok(COption::None),
[1, 0, 0, 0] => Ok(COption::Some(Pubkey::new_from_array(*body))),
_ => Err(ProgramError::InvalidAccountData),
}
}
/// Extract the mint authority from the account bytes
pub fn get_mint_authority(account_data: &[u8]) -> Result<COption<Pubkey>, ProgramError> {
const MINT_SIZE: usize = 82;
if account_data.len() < MINT_SIZE {
Err(ProgramError::InvalidAccountData)
} else {
let mint_authority = array_ref![account_data, 0, 36];
unpack_coption_key(mint_authority)
}
}

View File

@ -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::integer_arithmetic)]
#![deny(missing_docs)]
#![cfg_attr(not(test), forbid(unsafe_code))]
pub mod inline_spl_token;
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;

View File

@ -0,0 +1,139 @@
//! Program state processor
use {
crate::inline_spl_token,
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::state::ExtraAccountMetas,
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},
},
spl_type_length_value::state::TlvStateBorrowed,
};
/// 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)?;
// 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()?;
let state = TlvStateBorrowed::unpack(&data).unwrap();
let extra_account_metas =
ExtraAccountMetas::unpack_with_tlv_state::<ExecuteInstruction>(&state)?;
// if incorrect number of are provided, error
let extra_account_infos = account_info_iter.as_slice();
let account_metas = extra_account_metas.data();
if extra_account_infos.len() != account_metas.len() {
return Err(TransferHookError::IncorrectAccount.into());
}
// Let's assume that they're provided in the correct order
for (i, account_info) in extra_account_infos.iter().enumerate() {
if &account_metas[i] != account_info {
return Err(TransferHookError::IncorrectAccount.into());
}
}
Ok(())
}
/// Processes a [InitializeExtraAccountMetas](enum.TransferHookInstruction.html) instruction.
pub fn process_initialize_extra_account_metas(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> 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_authority = inline_spl_token::get_mint_authority(&mint_info.try_borrow_data()?)?;
let mint_authority = 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 extra_account_infos = account_info_iter.as_slice();
let length = extra_account_infos.len();
let account_size = ExtraAccountMetas::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()?;
ExtraAccountMetas::init_with_account_infos::<ExecuteInstruction>(
&mut data,
extra_account_infos,
)?;
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::InitializeExtraAccountMetas => {
msg!("Instruction: InitializeExtraAccountMetas");
process_initialize_extra_account_metas(program_id, accounts)
}
}
}

View File

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

View File

@ -0,0 +1,495 @@
// 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::{self, sync::Mutex},
ProgramTest, ProgramTestContext,
},
solana_sdk::{
account_info::AccountInfo,
entrypoint::ProgramResult,
instruction::{AccountMeta, InstructionError},
pubkey::Pubkey,
signature::Signer,
signer::keypair::Keypair,
system_instruction, sysvar,
transaction::{Transaction, TransactionError},
},
spl_tlv_account_resolution::state::ExtraAccountMetas,
spl_token_client::{
client::{
ProgramBanksClient, ProgramBanksClientProcessTransaction, ProgramClient,
SendTransaction,
},
token::Token,
},
spl_transfer_hook_interface::{
error::TransferHookError,
get_extra_account_metas_address,
instruction::{execute_with_extra_account_metas, initialize_extra_account_metas},
invoke,
},
std::sync::Arc,
};
fn keypair_clone(kp: &Keypair) -> Keypair {
Keypair::from_bytes(&kp.to_bytes()).expect("failed to copy keypair")
}
async fn setup(
program_id: &Pubkey,
) -> (
Arc<Mutex<ProgramTestContext>>,
Arc<dyn ProgramClient<ProgramBanksClientProcessTransaction>>,
Arc<Keypair>,
) {
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),
);
let context = program_test.start_with_context().await;
let payer = Arc::new(keypair_clone(&context.payer));
let context = Arc::new(Mutex::new(context));
let client: Arc<dyn ProgramClient<ProgramBanksClientProcessTransaction>> =
Arc::new(ProgramBanksClient::new_from_context(
Arc::clone(&context),
ProgramBanksClientProcessTransaction,
));
(context, client, payer)
}
async fn setup_mint<T: SendTransaction>(
program_id: &Pubkey,
mint_authority: &Pubkey,
decimals: u8,
payer: Arc<Keypair>,
client: Arc<dyn ProgramClient<T>>,
) -> Token<T> {
let mint_account = Keypair::new();
let token = Token::new(
client,
program_id,
&mint_account.pubkey(),
Some(decimals),
payer,
);
token
.create_mint(mint_authority, None, vec![], &[&mint_account])
.await
.unwrap();
token
}
#[tokio::test]
async fn success() {
let program_id = Pubkey::new_unique();
let (context, client, payer) = setup(&program_id).await;
let token_program_id = spl_token_2022::id();
let wallet = Keypair::new();
let mint_authority = Keypair::new();
let mint_authority_pubkey = mint_authority.pubkey();
let decimals = 2;
let token = setup_mint(
&token_program_id,
&mint_authority_pubkey,
decimals,
payer.clone(),
client.clone(),
)
.await;
let extra_account_metas = get_extra_account_metas_address(token.get_address(), &program_id);
token
.create_associated_token_account(&wallet.pubkey())
.await
.unwrap();
let source = token.get_associated_token_address(&wallet.pubkey());
let token_amount = 1_000_000_000_000;
token
.mint_to(
&source,
&mint_authority_pubkey,
token_amount,
&[&mint_authority],
)
.await
.unwrap();
let destination = Keypair::new();
token
.create_auxiliary_token_account(&destination, &wallet.pubkey())
.await
.unwrap();
let destination = destination.pubkey();
let extra_account_pubkeys = [
AccountMeta::new_readonly(sysvar::instructions::id(), false),
AccountMeta::new_readonly(mint_authority_pubkey, true),
AccountMeta::new(extra_account_metas, false),
];
let mut context = context.lock().await;
let rent = context.banks_client.get_rent().await.unwrap();
let rent_lamports =
rent.minimum_balance(ExtraAccountMetas::size_of(extra_account_pubkeys.len()).unwrap());
let transaction = Transaction::new_signed_with_payer(
&[
system_instruction::transfer(
&context.payer.pubkey(),
&extra_account_metas,
rent_lamports,
),
initialize_extra_account_metas(
&program_id,
&extra_account_metas,
token.get_address(),
&mint_authority_pubkey,
&extra_account_pubkeys,
),
],
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,
token.get_address(),
&destination,
&wallet.pubkey(),
&extra_account_metas,
&extra_account_pubkeys[..2],
0,
)],
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(TransferHookError::IncorrectAccount as u32),
)
);
}
// fail with wrong account
{
let extra_account_pubkeys = [
AccountMeta::new_readonly(sysvar::instructions::id(), false),
AccountMeta::new_readonly(mint_authority_pubkey, true),
AccountMeta::new(wallet.pubkey(), false),
];
let transaction = Transaction::new_signed_with_payer(
&[execute_with_extra_account_metas(
&program_id,
&source,
token.get_address(),
&destination,
&wallet.pubkey(),
&extra_account_metas,
&extra_account_pubkeys,
0,
)],
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(TransferHookError::IncorrectAccount as u32),
)
);
}
// fail with not signer
{
let extra_account_pubkeys = [
AccountMeta::new_readonly(sysvar::instructions::id(), false),
AccountMeta::new_readonly(mint_authority_pubkey, false),
AccountMeta::new(extra_account_metas, false),
];
let transaction = Transaction::new_signed_with_payer(
&[execute_with_extra_account_metas(
&program_id,
&source,
token.get_address(),
&destination,
&wallet.pubkey(),
&extra_account_metas,
&extra_account_pubkeys,
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::IncorrectAccount as u32),
)
);
}
// success with correct params
{
let transaction = Transaction::new_signed_with_payer(
&[execute_with_extra_account_metas(
&program_id,
&source,
token.get_address(),
&destination,
&wallet.pubkey(),
&extra_account_metas,
&extra_account_pubkeys,
0,
)],
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 (context, client, payer) = setup(&program_id).await;
let token_program_id = spl_token_2022::id();
let mint_authority = Keypair::new();
let mint_authority_pubkey = mint_authority.pubkey();
let decimals = 2;
let token = setup_mint(
&token_program_id,
&mint_authority_pubkey,
decimals,
payer.clone(),
client.clone(),
)
.await;
// wrong derivation
let extra_account_metas = get_extra_account_metas_address(&program_id, token.get_address());
let mut context = context.lock().await;
let rent = context.banks_client.get_rent().await.unwrap();
let rent_lamports = rent.minimum_balance(ExtraAccountMetas::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_metas(
&program_id,
&extra_account_metas,
token.get_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 {
invoke::execute(
accounts[0].key,
accounts[1].clone(),
accounts[2].clone(),
accounts[3].clone(),
accounts[4].clone(),
&accounts[4..],
0,
)
}
#[tokio::test]
async fn success_on_chain_invoke() {
let hook_program_id = Pubkey::new_unique();
let mut program_test = ProgramTest::new(
"spl_transfer_hook_example",
hook_program_id,
processor!(spl_transfer_hook_example::processor::process),
);
program_test.prefer_bpf(false);
program_test.add_program(
"spl_token_2022",
spl_token_2022::id(),
processor!(spl_token_2022::processor::Processor::process),
);
let program_id = Pubkey::new_unique();
program_test.add_program(
"test_cpi_program",
program_id,
processor!(process_instruction),
);
let context = program_test.start_with_context().await;
let payer = Arc::new(keypair_clone(&context.payer));
let context = Arc::new(Mutex::new(context));
let client: Arc<dyn ProgramClient<ProgramBanksClientProcessTransaction>> =
Arc::new(ProgramBanksClient::new_from_context(
Arc::clone(&context),
ProgramBanksClientProcessTransaction,
));
let token_program_id = spl_token_2022::id();
let wallet = Keypair::new();
let mint_authority = Keypair::new();
let mint_authority_pubkey = mint_authority.pubkey();
let decimals = 2;
let token = setup_mint(
&token_program_id,
&mint_authority_pubkey,
decimals,
payer.clone(),
client.clone(),
)
.await;
let extra_account_metas =
get_extra_account_metas_address(token.get_address(), &hook_program_id);
let source = Pubkey::new_unique();
let destination = Pubkey::new_unique();
let extra_account_pubkeys = [
AccountMeta::new_readonly(sysvar::instructions::id(), false),
AccountMeta::new_readonly(mint_authority_pubkey, true),
AccountMeta::new(extra_account_metas, false),
];
let mut context = context.lock().await;
let rent = context.banks_client.get_rent().await.unwrap();
let rent_lamports =
rent.minimum_balance(ExtraAccountMetas::size_of(extra_account_pubkeys.len()).unwrap());
let transaction = Transaction::new_signed_with_payer(
&[
system_instruction::transfer(
&context.payer.pubkey(),
&extra_account_metas,
rent_lamports,
),
initialize_extra_account_metas(
&hook_program_id,
&extra_account_metas,
token.get_address(),
&mint_authority_pubkey,
&extra_account_pubkeys,
),
],
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,
token.get_address(),
&destination,
&wallet.pubkey(),
&extra_account_metas,
&extra_account_pubkeys,
0,
);
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();
}

View File

@ -0,0 +1,29 @@
[package]
name = "spl-transfer-hook-interface"
version = "0.1.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"
[features]
offchain-client = ["dep:solana-sdk"]
[dependencies]
arrayref = "0.3.6"
bytemuck = { version = "1.13.0", features = ["derive"] }
num-derive = "0.3"
num-traits = "0.2"
num_enum = "0.5.9"
solana-sdk = { version = "1.14.12", optional = true }
solana-program = "1.14.12"
spl-tlv-account-resolution = { version = "0.1.0" , path = "../../libraries/tlv-account-resolution" }
spl-type-length-value = { version = "0.1.0" , path = "../../libraries/type-length-value" }
thiserror = "1.0"
[lib]
crate-type = ["cdylib", "lib"]
[package.metadata.docs.rs]
targets = ["x86_64-unknown-linux-gnu"]

View File

@ -0,0 +1,106 @@
## 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::ExtraAccountMetas,
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);
}
// Get the extra account metas from the account data
let data = extra_account_metas_info.try_borrow_data()?;
let state = TlvStateBorrowed::unpack(&data).unwrap();
let extra_account_metas =
ExtraAccountMetas::unpack_with_tlv_state::<ExecuteInstruction>(&state)?;
// If incorrect number of accounts is provided, error
let extra_account_infos = account_info_iter.as_slice();
let account_metas = extra_account_metas.data();
if extra_account_infos.len() != account_metas.len() {
return Err(ProgramError::InvalidInstructionData);
}
// Let's require that they're provided in the correct order
for (i, account_info) in extra_account_infos.iter().enumerate() {
if &account_metas[i] != account_info {
return Err(ProgramError::InvalidInstructionData);
}
}
Ok(())
}
```
### Motivation
Token creators may need more control over transfers of their token. 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 give more flexibility to token creators and improve the situation for everyone,
`spl-transfer-hook-interface` introduces the concept of an interface integrated
with `spl-token-2022`. 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.
A developer must implement the `Execute` instruction, and the
`InitializeExtraAccountMetas` instruction to write the required additional account
pubkeys into the program-derived address defined by the mint and program id.
Side note: it's technically not required to implement `InitializeExtraAccountMetas`
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!
This 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.

View File

@ -0,0 +1,54 @@
//! Error types
use {
num_derive::FromPrimitive,
solana_program::{
decode_error::DecodeError,
msg,
program_error::{PrintProgramError, ProgramError},
},
thiserror::Error,
};
/// Errors that may be returned by the interface.
#[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)]
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,
}
impl From<TransferHookError> for ProgramError {
fn from(e: TransferHookError) -> Self {
ProgramError::Custom(e as u32)
}
}
impl<T> DecodeError<T> for TransferHookError {
fn type_of() -> &'static str {
"TransferHookError"
}
}
impl PrintProgramError for TransferHookError {
fn print<E>(&self)
where
E: 'static
+ std::error::Error
+ DecodeError<E>
+ PrintProgramError
+ num_traits::FromPrimitive,
{
match self {
Self::IncorrectAccount => msg!("Incorrect account provided"),
Self::MintHasNoMintAuthority => msg!("Mint has no mint authority"),
Self::IncorrectMintAuthority => {
msg!("Incorrect mint authority has signed the instruction")
}
}
}
}

View File

@ -0,0 +1,211 @@
//! Instruction types
use {
solana_program::{
instruction::{AccountMeta, Instruction},
program_error::ProgramError,
pubkey::Pubkey,
system_program,
},
spl_type_length_value::discriminator::{Discriminator, TlvDiscriminator},
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
/// 4..4+M `[]` `M` additional accounts, to be written to validation data
///
InitializeExtraAccountMetas,
}
/// TLV instruction type only used to define the discriminator. The actual data
/// is entirely managed by `ExtraAccountMetas`, and it is the only data contained
/// by this type.
pub struct ExecuteInstruction;
impl TlvDiscriminator for ExecuteInstruction {
/// Please use this discriminator in your program when matching
const TLV_DISCRIMINATOR: Discriminator = Discriminator::new(EXECUTE_DISCRIMINATOR);
}
/// First 8 bytes of `hash::hashv(&["spl-transfer-hook-interface:execute"])`
const EXECUTE_DISCRIMINATOR: [u8; Discriminator::LENGTH] = [105, 37, 101, 197, 75, 251, 102, 26];
// annoying, but needed to perform a match on the value
const EXECUTE_DISCRIMINATOR_SLICE: &[u8] = &EXECUTE_DISCRIMINATOR;
/// First 8 bytes of `hash::hashv(&["spl-transfer-hook-interface:initialize-extra-account-metas"])`
const INITIALIZE_EXTRA_ACCOUNT_METAS_DISCRIMINATOR: &[u8] = &[43, 34, 13, 49, 167, 88, 235, 235];
impl TransferHookInstruction {
/// Unpacks a byte buffer into a [TransferHookInstruction](enum.TransferHookInstruction.html).
pub fn unpack(input: &[u8]) -> Result<Self, ProgramError> {
if input.len() < Discriminator::LENGTH {
return Err(ProgramError::InvalidInstructionData);
}
let (discriminator, rest) = input.split_at(Discriminator::LENGTH);
Ok(match discriminator {
EXECUTE_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 }
}
INITIALIZE_EXTRA_ACCOUNT_METAS_DISCRIMINATOR => Self::InitializeExtraAccountMetas,
_ => 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(EXECUTE_DISCRIMINATOR_SLICE);
buf.extend_from_slice(&amount.to_le_bytes());
}
Self::InitializeExtraAccountMetas => {
buf.extend_from_slice(INITIALIZE_EXTRA_ACCOUNT_METAS_DISCRIMINATOR);
}
};
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 `InitializeExtraAccountMetas` instruction.
pub fn initialize_extra_account_metas(
program_id: &Pubkey,
extra_account_metas_pubkey: &Pubkey,
mint_pubkey: &Pubkey,
authority_pubkey: &Pubkey,
additional_accounts: &[AccountMeta],
) -> Instruction {
let data = TransferHookInstruction::InitializeExtraAccountMetas.pack();
let mut 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),
];
accounts.extend_from_slice(additional_accounts);
Instruction {
program_id: *program_id,
accounts,
data,
}
}
#[cfg(test)]
mod test {
use {super::*, crate::NAMESPACE, solana_program::hash};
#[test]
fn validate_packing() {
let amount = 111_111_111;
let check = TransferHookInstruction::Execute { amount };
let packed = check.pack();
// Please use ExecuteInstruction::TLV_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()[..Discriminator::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 check = TransferHookInstruction::InitializeExtraAccountMetas;
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()[..Discriminator::LENGTH];
let mut expect = vec![];
expect.extend_from_slice(discriminator.as_ref());
assert_eq!(packed, expect);
let unpacked = TransferHookInstruction::unpack(&expect).unwrap();
assert_eq!(unpacked, check);
}
}

View File

@ -0,0 +1,51 @@
//! 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, program::invoke, pubkey::Pubkey,
},
spl_tlv_account_resolution::state::ExtraAccountMetas,
};
/// Helper to CPI into a transfer-hook program on-chain, looking through the
/// additional account infos to create the proper instruction
pub fn 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(),
];
ExtraAccountMetas::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)
}

View File

@ -0,0 +1,53 @@
//! 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::integer_arithmetic)]
#![deny(missing_docs)]
#![cfg_attr(not(test), forbid(unsafe_code))]
pub mod error;
pub mod instruction;
pub mod invoke;
#[cfg(feature = "offchain-client")]
pub mod offchain;
// 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]
}

View File

@ -0,0 +1,64 @@
//! Offchain helper for fetching required accounts to build instructions
use {
crate::{get_extra_account_metas_address, instruction::ExecuteInstruction},
solana_sdk::{
account::Account, instruction::AccountMeta, program_error::ProgramError, pubkey::Pubkey,
},
spl_tlv_account_resolution::state::ExtraAccountMetas,
std::future::Future,
};
type AccountResult = Result<Option<Account>, AccountFetchError>;
type AccountFetchError = Box<dyn std::error::Error + Send + Sync>;
/// Offchain helper to get all additional required account metas for a mint
///
/// To be client-agnostic, this simply takes a function that will return a
/// `Future<Account>` for the given address. Can be called in the following way:
///
/// ```rust,ignore
/// use solana_client::nonblocking::rpc_client::RpcClient;
/// use solana_sdk::pubkey::Pubkey;
///
/// let program_id = Pubkey::new_unique();
/// let mint = Pubkey::new_unique();
/// let client = RpcClient::new_mock("succeeds".to_string());
///
/// let extra_account_metas = get_extra_account_metas(
/// |address| self.client.get_account(&address),
/// &program_id,
/// &mint,
/// ).await?;
/// ```
pub async fn get_extra_account_metas<F, Fut>(
get_account_fn: F,
permissioned_transfer_program_id: &Pubkey,
mint: &Pubkey,
) -> Result<Vec<AccountMeta>, AccountFetchError>
where
F: Fn(Pubkey) -> Fut,
Fut: Future<Output = AccountResult>,
{
let mut instruction_metas = vec![];
let validation_address =
get_extra_account_metas_address(mint, permissioned_transfer_program_id);
let validation_account = get_account_fn(validation_address)
.await?
.ok_or(ProgramError::InvalidAccountData)?;
ExtraAccountMetas::add_to_vec::<ExecuteInstruction>(
&mut instruction_metas,
&validation_account.data,
)?;
instruction_metas.push(AccountMeta {
pubkey: *permissioned_transfer_program_id,
is_signer: false,
is_writable: false,
});
instruction_metas.push(AccountMeta {
pubkey: validation_address,
is_signer: false,
is_writable: false,
});
Ok(instruction_metas)
}