From d92664f6dfa8e1e059dbcea8c72919bcd253b557 Mon Sep 17 00:00:00 2001 From: Jon Cinque Date: Tue, 2 May 2023 22:21:42 +0200 Subject: [PATCH] transfer-hook-interface: Add interface defining a hook called during token-2022 transfer (#4147) --- .github/workflows/pull-request-token.yml | 3 + Cargo.lock | 31 ++ Cargo.toml | 2 + libraries/tlv-account-resolution/src/state.rs | 28 +- .../type-length-value/src/discriminator.rs | 5 + token/transfer-hook-example/Cargo.toml | 31 ++ token/transfer-hook-example/README.md | 54 ++ token/transfer-hook-example/src/entrypoint.rs | 24 + .../src/inline_spl_token.rs | 29 + token/transfer-hook-example/src/lib.rs | 18 + token/transfer-hook-example/src/processor.rs | 139 +++++ token/transfer-hook-example/src/state.rs | 27 + .../transfer-hook-example/tests/functional.rs | 495 ++++++++++++++++++ token/transfer-hook-interface/Cargo.toml | 29 + token/transfer-hook-interface/README.md | 106 ++++ token/transfer-hook-interface/src/error.rs | 54 ++ .../src/instruction.rs | 211 ++++++++ token/transfer-hook-interface/src/invoke.rs | 51 ++ token/transfer-hook-interface/src/lib.rs | 53 ++ token/transfer-hook-interface/src/offchain.rs | 64 +++ 20 files changed, 1449 insertions(+), 5 deletions(-) create mode 100644 token/transfer-hook-example/Cargo.toml create mode 100644 token/transfer-hook-example/README.md create mode 100644 token/transfer-hook-example/src/entrypoint.rs create mode 100644 token/transfer-hook-example/src/inline_spl_token.rs create mode 100644 token/transfer-hook-example/src/lib.rs create mode 100644 token/transfer-hook-example/src/processor.rs create mode 100644 token/transfer-hook-example/src/state.rs create mode 100644 token/transfer-hook-example/tests/functional.rs create mode 100644 token/transfer-hook-interface/Cargo.toml create mode 100644 token/transfer-hook-interface/README.md create mode 100644 token/transfer-hook-interface/src/error.rs create mode 100644 token/transfer-hook-interface/src/instruction.rs create mode 100644 token/transfer-hook-interface/src/invoke.rs create mode 100644 token/transfer-hook-interface/src/lib.rs create mode 100644 token/transfer-hook-interface/src/offchain.rs diff --git a/.github/workflows/pull-request-token.yml b/.github/workflows/pull-request-token.yml index 68a9a50b..47b062e9 100644 --- a/.github/workflows/pull-request-token.yml +++ b/.github/workflows/pull-request-token.yml @@ -64,6 +64,9 @@ jobs: - name: Build and test ATA 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 run: | cargo +"${{ env.RUST_STABLE }}" test \ diff --git a/Cargo.lock b/Cargo.lock index 0b7ea22f..5d3fa529 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6618,6 +6618,37 @@ dependencies = [ "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]] name = "spl-type-length-value" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 37a584d0..153b57ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,8 @@ members = [ "token/program", "token/program-2022", "token/program-2022-test", + "token/transfer-hook-example", + "token/transfer-hook-interface", "token/client", "utils/cgen", "utils/test-client", diff --git a/libraries/tlv-account-resolution/src/state.rs b/libraries/tlv-account-resolution/src/state.rs index ddbfc058..c45886a2 100644 --- a/libraries/tlv-account-resolution/src/state.rs +++ b/libraries/tlv-account-resolution/src/state.rs @@ -102,6 +102,18 @@ impl ExtraAccountMetas { Self::init::(data, account_infos) } + /// Get the underlying `PodSlice` 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, ProgramError> { + let bytes = tlv_state.get_bytes::()?; + PodSlice::::unpack(bytes) + } + /// Initialize a TLV entry for the given discriminator, populating the data /// with the given account metas pub fn init_with_account_metas( @@ -118,19 +130,25 @@ impl ExtraAccountMetas { } /// Add the additional account metas to an existing instruction - pub fn add_to_instruction( - instruction: &mut Instruction, + pub fn add_to_vec( + account_metas: &mut Vec, data: &[u8], ) -> Result<(), ProgramError> { let state = TlvStateBorrowed::unpack(data)?; let bytes = state.get_bytes::()?; let extra_account_metas = PodSlice::::unpack(bytes)?; - instruction - .accounts - .extend(extra_account_metas.data().iter().map(|m| m.into())); + account_metas.extend(extra_account_metas.data().iter().map(|m| m.into())); Ok(()) } + /// Add the additional account metas to an existing instruction + pub fn add_to_instruction( + instruction: &mut Instruction, + data: &[u8], + ) -> Result<(), ProgramError> { + Self::add_to_vec::(&mut instruction.accounts, data) + } + /// Add the additional account metas and account infos for a CPI pub fn add_to_cpi_instruction<'a, T: TlvDiscriminator>( cpi_instruction: &mut Instruction, diff --git a/libraries/type-length-value/src/discriminator.rs b/libraries/type-length-value/src/discriminator.rs index a9c8e305..420b2379 100644 --- a/libraries/type-length-value/src/discriminator.rs +++ b/libraries/type-length-value/src/discriminator.rs @@ -34,6 +34,11 @@ impl AsRef<[u8]> for Discriminator { &self.0[..] } } +impl AsRef<[u8; Discriminator::LENGTH]> for Discriminator { + fn as_ref(&self) -> &[u8; Discriminator::LENGTH] { + &self.0 + } +} impl From for Discriminator { fn from(from: u64) -> Self { Self(from.to_le_bytes()) diff --git a/token/transfer-hook-example/Cargo.toml b/token/transfer-hook-example/Cargo.toml new file mode 100644 index 00000000..a8ecf512 --- /dev/null +++ b/token/transfer-hook-example/Cargo.toml @@ -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 "] +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"] diff --git a/token/transfer-hook-example/README.md b/token/transfer-hook-example/README.md new file mode 100644 index 00000000..24cb33f5 --- /dev/null +++ b/token/transfer-hook-example/README.md @@ -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! +} +``` diff --git a/token/transfer-hook-example/src/entrypoint.rs b/token/transfer-hook-example/src/entrypoint.rs new file mode 100644 index 00000000..14a2bc5b --- /dev/null +++ b/token/transfer-hook-example/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::(); + return Err(error); + } + Ok(()) +} diff --git a/token/transfer-hook-example/src/inline_spl_token.rs b/token/transfer-hook-example/src/inline_spl_token.rs new file mode 100644 index 00000000..566e3a5c --- /dev/null +++ b/token/transfer-hook-example/src/inline_spl_token.rs @@ -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, 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, 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) + } +} diff --git a/token/transfer-hook-example/src/lib.rs b/token/transfer-hook-example/src/lib.rs new file mode 100644 index 00000000..593a78ee --- /dev/null +++ b/token/transfer-hook-example/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::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; diff --git a/token/transfer-hook-example/src/processor.rs b/token/transfer-hook-example/src/processor.rs new file mode 100644 index 00000000..ea51a697 --- /dev/null +++ b/token/transfer-hook-example/src/processor.rs @@ -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::(&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::( + &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) + } + } +} diff --git a/token/transfer-hook-example/src/state.rs b/token/transfer-hook-example/src/state.rs new file mode 100644 index 00000000..eb78f699 --- /dev/null +++ b/token/transfer-hook-example/src/state.rs @@ -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, 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::(&mut data, &account_metas)?; + Ok(data) +} diff --git a/token/transfer-hook-example/tests/functional.rs b/token/transfer-hook-example/tests/functional.rs new file mode 100644 index 00000000..a3a7045e --- /dev/null +++ b/token/transfer-hook-example/tests/functional.rs @@ -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>, + Arc>, + Arc, +) { + 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> = + Arc::new(ProgramBanksClient::new_from_context( + Arc::clone(&context), + ProgramBanksClientProcessTransaction, + )); + (context, client, payer) +} + +async fn setup_mint( + program_id: &Pubkey, + mint_authority: &Pubkey, + decimals: u8, + payer: Arc, + client: Arc>, +) -> Token { + 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> = + 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(); +} diff --git a/token/transfer-hook-interface/Cargo.toml b/token/transfer-hook-interface/Cargo.toml new file mode 100644 index 00000000..8ebe6272 --- /dev/null +++ b/token/transfer-hook-interface/Cargo.toml @@ -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 "] +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"] diff --git a/token/transfer-hook-interface/README.md b/token/transfer-hook-interface/README.md new file mode 100644 index 00000000..6c988715 --- /dev/null +++ b/token/transfer-hook-interface/README.md @@ -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::(&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. diff --git a/token/transfer-hook-interface/src/error.rs b/token/transfer-hook-interface/src/error.rs new file mode 100644 index 00000000..2ea29b83 --- /dev/null +++ b/token/transfer-hook-interface/src/error.rs @@ -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 for ProgramError { + fn from(e: TransferHookError) -> Self { + ProgramError::Custom(e as u32) + } +} +impl DecodeError for TransferHookError { + fn type_of() -> &'static str { + "TransferHookError" + } +} + +impl PrintProgramError for TransferHookError { + fn print(&self) + where + E: 'static + + std::error::Error + + DecodeError + + 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") + } + } + } +} diff --git a/token/transfer-hook-interface/src/instruction.rs b/token/transfer-hook-interface/src/instruction.rs new file mode 100644 index 00000000..89af2210 --- /dev/null +++ b/token/transfer-hook-interface/src/instruction.rs @@ -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 { + 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 { + 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); + } +} diff --git a/token/transfer-hook-interface/src/invoke.rs b/token/transfer-hook-interface/src/invoke.rs new file mode 100644 index 00000000..91d63f03 --- /dev/null +++ b/token/transfer-hook-interface/src/invoke.rs @@ -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::( + &mut cpi_instruction, + &mut cpi_account_infos, + &validation_info.try_borrow_data()?, + additional_accounts, + )?; + invoke(&cpi_instruction, &cpi_account_infos) +} diff --git a/token/transfer-hook-interface/src/lib.rs b/token/transfer-hook-interface/src/lib.rs new file mode 100644 index 00000000..7d2bcd0f --- /dev/null +++ b/token/transfer-hook-interface/src/lib.rs @@ -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] +} diff --git a/token/transfer-hook-interface/src/offchain.rs b/token/transfer-hook-interface/src/offchain.rs new file mode 100644 index 00000000..aef9bd78 --- /dev/null +++ b/token/transfer-hook-interface/src/offchain.rs @@ -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, AccountFetchError>; +type AccountFetchError = Box; + +/// 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` 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( + get_account_fn: F, + permissioned_transfer_program_id: &Pubkey, + mint: &Pubkey, +) -> Result, AccountFetchError> +where + F: Fn(Pubkey) -> Fut, + Fut: Future, +{ + 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::( + &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) +}