From 47b2d1a03167098ce5248f085297a6b607c6caa1 Mon Sep 17 00:00:00 2001 From: burhankhaja Date: Fri, 14 Nov 2025 11:23:21 +0000 Subject: [PATCH 1/2] token-2022: Add transfer hook extension Co-authored-by: zubayr1 --- .../src/instructions/extensions/mod.rs | 27 ++++ .../transfer_hook/instructions/initialize.rs | 83 +++++++++++ .../transfer_hook/instructions/mod.rs | 5 + .../transfer_hook/instructions/update.rs | 137 ++++++++++++++++++ .../extensions/transfer_hook/mod.rs | 5 + .../extensions/transfer_hook/state.rs | 39 +++++ 6 files changed, 296 insertions(+) create mode 100644 programs/token-2022/src/instructions/extensions/mod.rs create mode 100644 programs/token-2022/src/instructions/extensions/transfer_hook/instructions/initialize.rs create mode 100644 programs/token-2022/src/instructions/extensions/transfer_hook/instructions/mod.rs create mode 100644 programs/token-2022/src/instructions/extensions/transfer_hook/instructions/update.rs create mode 100644 programs/token-2022/src/instructions/extensions/transfer_hook/mod.rs create mode 100644 programs/token-2022/src/instructions/extensions/transfer_hook/state.rs diff --git a/programs/token-2022/src/instructions/extensions/mod.rs b/programs/token-2022/src/instructions/extensions/mod.rs new file mode 100644 index 00000000..f44910ae --- /dev/null +++ b/programs/token-2022/src/instructions/extensions/mod.rs @@ -0,0 +1,27 @@ +pub mod transfer_hook; + +#[repr(u8)] +pub(crate) enum ExtensionDiscriminator { + /// Default Account State extension + DefaultAccountState = 28, + /// Memo Transfer extension + MemoTransfer = 30, + /// Interest-Bearing Mint extension + InterestBearingMint = 33, + /// CPI Guard extension + CpiGuard = 34, + /// Permanent Delegate extension + PermanentDelegate = 35, + /// Transfer Hook extension + TransferHook = 36, + /// Metadata Pointer extension + MetadataPointer = 39, + /// Group Pointer extension + GroupPointer = 40, + /// Group Member Pointer extension + GroupMemberPointer = 41, + /// Scaled UI Amount extension + ScaledUiAmount = 43, + /// Pausable extension + Pausable = 44, +} diff --git a/programs/token-2022/src/instructions/extensions/transfer_hook/instructions/initialize.rs b/programs/token-2022/src/instructions/extensions/transfer_hook/instructions/initialize.rs new file mode 100644 index 00000000..03d4e982 --- /dev/null +++ b/programs/token-2022/src/instructions/extensions/transfer_hook/instructions/initialize.rs @@ -0,0 +1,83 @@ +use { + crate::instructions::extensions::{ + transfer_hook::state::{ + offset_transfer_hook_initialize as OFFSET, TransferHookInstruction, + }, + ExtensionDiscriminator, + }, + pinocchio::{ + account_info::AccountInfo, + cpi::invoke_signed, + instruction::{AccountMeta, Instruction, Signer}, + pubkey::Pubkey, + ProgramResult, + }, +}; + +pub struct InitializeTransferHook<'a, 'b> { + /// Mint Account to initialize. + pub mint_account: &'a AccountInfo, + /// Optional authority that can set the transfer hook program id + pub authority: Option<&'b Pubkey>, + /// Program that authorizes the transfer + pub program_id: Option<&'b Pubkey>, + /// Token Program + pub token_program: &'b Pubkey, +} + +impl InitializeTransferHook<'_, '_> { + #[inline(always)] + pub fn invoke(&self) -> ProgramResult { + self.invoke_signed(&[]) + } + + #[inline(always)] + pub fn invoke_signed(&self, signers: &[Signer]) -> ProgramResult { + let account_metas = [AccountMeta::writable(self.mint_account.key())]; + + let mut buffer = [0u8; 66]; + let data = initialize_instruction_data(&mut buffer, self.authority, self.program_id); + + let instruction = Instruction { + program_id: self.token_program, + accounts: &account_metas, + data, + }; + + invoke_signed(&instruction, &[self.mint_account], signers) + } +} + +#[inline(always)] +fn initialize_instruction_data<'a>( + buffer: &'a mut [u8], + authority: Option<&Pubkey>, + program_id: Option<&Pubkey>, +) -> &'a [u8] { + let mut offset = OFFSET::START as usize; + + // Encode discriminators (TransferHook + Initialize) + buffer[..offset].copy_from_slice(&[ + ExtensionDiscriminator::TransferHook as u8, + TransferHookInstruction::Initialize as u8, + ]); + + // Set authority at offset [2..34] + if let Some(x) = authority { + buffer[offset..offset + OFFSET::AUTHORITY_PUBKEY as usize].copy_from_slice(x); + } else { + buffer[offset..offset + OFFSET::AUTHORITY_PUBKEY as usize].copy_from_slice(&[0; 32]); + } + + // shift offset past authority pubkey + offset += OFFSET::AUTHORITY_PUBKEY as usize; + + // Set program_id at offset [34..66] + if let Some(x) = program_id { + buffer[offset..OFFSET::END as usize].copy_from_slice(x); + } else { + buffer[offset..OFFSET::END as usize].copy_from_slice(&[0; 32]); + } + + buffer +} diff --git a/programs/token-2022/src/instructions/extensions/transfer_hook/instructions/mod.rs b/programs/token-2022/src/instructions/extensions/transfer_hook/instructions/mod.rs new file mode 100644 index 00000000..f73e2b4f --- /dev/null +++ b/programs/token-2022/src/instructions/extensions/transfer_hook/instructions/mod.rs @@ -0,0 +1,5 @@ +pub mod initialize; +pub mod update; + +pub use initialize::*; +pub use update::*; diff --git a/programs/token-2022/src/instructions/extensions/transfer_hook/instructions/update.rs b/programs/token-2022/src/instructions/extensions/transfer_hook/instructions/update.rs new file mode 100644 index 00000000..6f8032a8 --- /dev/null +++ b/programs/token-2022/src/instructions/extensions/transfer_hook/instructions/update.rs @@ -0,0 +1,137 @@ +use { + crate::{ + instructions::extensions::{ + transfer_hook::state::{ + offset_transfer_hook_update as OFFSET, TransferHookInstruction, + }, + ExtensionDiscriminator, + }, + instructions::MAX_MULTISIG_SIGNERS, + }, + core::{mem::MaybeUninit, slice}, + pinocchio::{ + account_info::AccountInfo, + cpi::invoke_signed_with_bounds, + instruction::{AccountMeta, Instruction, Signer}, + program_error::ProgramError, + pubkey::Pubkey, + ProgramResult, + }, +}; + +pub struct UpdateTransferHook<'a, 'b> { + /// Mint Account to update. + pub mint_account: &'a AccountInfo, + /// Authority Account. + pub authority: &'a AccountInfo, + /// Signer Accounts (for multisig support) + pub signers: &'a [AccountInfo], + /// Program that authorizes the transfer + pub program_id: Option<&'b Pubkey>, + /// Token Program + pub token_program: &'b Pubkey, +} + +impl UpdateTransferHook<'_, '_> { + #[inline(always)] + pub fn invoke(&self) -> ProgramResult { + self.invoke_signed(&[]) + } + + #[inline(always)] + pub fn invoke_signed(&self, signers: &[Signer]) -> ProgramResult { + let &Self { + mint_account, + authority, + signers: account_signers, + token_program, + .. + } = self; + + if account_signers.len() > MAX_MULTISIG_SIGNERS { + Err(ProgramError::InvalidArgument)?; + } + + let num_accounts = 2 + account_signers.len(); + + // Account metadata + const UNINIT_META: MaybeUninit = MaybeUninit::::uninit(); + let mut acc_metas = [UNINIT_META; 2 + MAX_MULTISIG_SIGNERS]; + + unsafe { + // SAFETY: + // - `account_metas` is sized to 2 + MAX_MULTISIG_SIGNERS + // - Index 0 is always present + acc_metas + .get_unchecked_mut(0) + .write(AccountMeta::writable(mint_account.key())); + // - Index 1 is always present + if account_signers.is_empty() { + acc_metas + .get_unchecked_mut(1) + .write(AccountMeta::readonly_signer(authority.key())); + } else { + acc_metas + .get_unchecked_mut(1) + .write(AccountMeta::readonly(authority.key())); + } + } + + for (account_meta, signer) in acc_metas[2..].iter_mut().zip(account_signers.iter()) { + account_meta.write(AccountMeta::readonly_signer(signer.key())); + } + + let mut buffer = [0u8; 34]; + let data = update_instruction_data(&mut buffer, self.program_id); + + let instruction = Instruction { + program_id: token_program, + accounts: unsafe { slice::from_raw_parts(acc_metas.as_ptr() as _, num_accounts) }, + data, + }; + + // Account info array + const UNINIT_INFO: MaybeUninit<&AccountInfo> = MaybeUninit::uninit(); + let mut acc_infos = [UNINIT_INFO; 2 + MAX_MULTISIG_SIGNERS]; + + unsafe { + // SAFETY: + // - `account_infos` is sized to 2 + MAX_MULTISIG_SIGNERS + // - Index 0 is always present + acc_infos.get_unchecked_mut(0).write(mint_account); + // - Index 1 is always present + acc_infos.get_unchecked_mut(1).write(authority); + } + + // Fill signer accounts + for (account_info, signer) in acc_infos[2..].iter_mut().zip(account_signers.iter()) { + account_info.write(signer); + } + + invoke_signed_with_bounds::<{ 2 + MAX_MULTISIG_SIGNERS }>( + &instruction, + unsafe { slice::from_raw_parts(acc_infos.as_ptr() as _, num_accounts) }, + signers, + ) + } +} + +#[inline(always)] +fn update_instruction_data<'a>(buffer: &'a mut [u8], program_id: Option<&Pubkey>) -> &'a [u8] { + let offset = OFFSET::START as usize; + + // Set discriminators (TransferHook + Update) + buffer[..offset].copy_from_slice(&[ + ExtensionDiscriminator::TransferHook as u8, + TransferHookInstruction::Update as u8, + ]); + + // Set program_id at offset [2..34] + if let Some(x) = program_id { + buffer[offset..OFFSET::END as usize].copy_from_slice(x); + } else { + buffer[offset..OFFSET::END as usize].copy_from_slice(&[0; 32]); + } + + buffer +} diff --git a/programs/token-2022/src/instructions/extensions/transfer_hook/mod.rs b/programs/token-2022/src/instructions/extensions/transfer_hook/mod.rs new file mode 100644 index 00000000..ec1f45c6 --- /dev/null +++ b/programs/token-2022/src/instructions/extensions/transfer_hook/mod.rs @@ -0,0 +1,5 @@ +pub mod instructions; +pub mod state; + +pub use instructions::*; +pub use state::*; diff --git a/programs/token-2022/src/instructions/extensions/transfer_hook/state.rs b/programs/token-2022/src/instructions/extensions/transfer_hook/state.rs new file mode 100644 index 00000000..b27881d7 --- /dev/null +++ b/programs/token-2022/src/instructions/extensions/transfer_hook/state.rs @@ -0,0 +1,39 @@ +use pinocchio::pubkey::Pubkey; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TransferHookInstruction { + Initialize, + Update, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TransferHook { + /// Authority that can set the transfer hook program id + authority: Pubkey, + /// Program that authorizes the transfer + program_id: Pubkey, +} + +/// Instruction data layout: +/// - [0]: instruction discriminator (1 byte, u8) +/// - [1]: instruction_type (1 byte, u8) +/// - [2..34]: authority (32 bytes, Pubkey) +/// - [34..66]: program_id (32 bytes, Pubkey) +pub mod offset_transfer_hook_initialize { + pub const START: u8 = 2; + pub const AUTHORITY_PUBKEY: u8 = 32; + pub const PROGRAM_ID_PUBKEY: u8 = 32; + pub const END: u8 = START + AUTHORITY_PUBKEY + PROGRAM_ID_PUBKEY; +} + +/// Instruction data layout: +/// - [0]: instruction discriminator (1 byte, u8) +/// - [1]: instruction_type (1 byte, u8) +/// - [2..34]: program_id (32 bytes, Pubkey) +pub mod offset_transfer_hook_update { + pub const START: u8 = 2; + pub const PROGRAM_ID_PUBKEY: u8 = 32; + pub const END: u8 = START + PROGRAM_ID_PUBKEY; +} From d2adb0fa1101de69759175598630a9e06f8d011d Mon Sep 17 00:00:00 2001 From: Burhan khaja <118989617+burhankhaja@users.noreply.github.com> Date: Fri, 14 Nov 2025 19:13:28 +0530 Subject: [PATCH 2/2] transfer-hook: delete extensions/mod.rs --- .../src/instructions/extensions/mod.rs | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 programs/token-2022/src/instructions/extensions/mod.rs diff --git a/programs/token-2022/src/instructions/extensions/mod.rs b/programs/token-2022/src/instructions/extensions/mod.rs deleted file mode 100644 index f44910ae..00000000 --- a/programs/token-2022/src/instructions/extensions/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -pub mod transfer_hook; - -#[repr(u8)] -pub(crate) enum ExtensionDiscriminator { - /// Default Account State extension - DefaultAccountState = 28, - /// Memo Transfer extension - MemoTransfer = 30, - /// Interest-Bearing Mint extension - InterestBearingMint = 33, - /// CPI Guard extension - CpiGuard = 34, - /// Permanent Delegate extension - PermanentDelegate = 35, - /// Transfer Hook extension - TransferHook = 36, - /// Metadata Pointer extension - MetadataPointer = 39, - /// Group Pointer extension - GroupPointer = 40, - /// Group Member Pointer extension - GroupMemberPointer = 41, - /// Scaled UI Amount extension - ScaledUiAmount = 43, - /// Pausable extension - Pausable = 44, -}